feat(tui): open subagents with down

This commit is contained in:
Dax Raad 2026-07-10 21:01:04 -04:00
commit 6ce62bd84a
10 changed files with 241 additions and 21 deletions

View file

@ -177,6 +177,7 @@ export function Prompt(props: PromptProps) {
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const liveWorkShortcut = useCommandShortcut("session.child.first")
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
@ -864,6 +865,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => {
return {
priority: 1,
target: inputTarget,
enabled: (() => {
cursorVersion()
@ -876,8 +878,12 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
run() {
if (input.cursorOffset !== 0) {
if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0
return false
if (input.scrollY + input.visualCursor.visualRow === 0) {
input.cursorOffset = 0
return
}
input.moveCursorUp()
return
}
const item = history.move(-1, input.plainText)
@ -896,6 +902,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => {
return {
priority: 1,
target: inputTarget,
enabled: (() => {
cursorVersion()
@ -911,9 +918,12 @@ export function Prompt(props: PromptProps) {
if (
input.scrollY + input.visualCursor.visualRow ===
Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
)
) {
input.cursorOffset = input.plainText.length
return false
return
}
input.moveCursorDown()
return
}
const item = history.move(1, input.plainText)
@ -1608,6 +1618,9 @@ export function Prompt(props: PromptProps) {
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={theme.textMuted} wrapMode="none">
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: theme.text }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
</Show>

View file

@ -100,7 +100,7 @@ export const Definitions = {
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
session_child_first: keybind("<leader>down", "Toggle subagent picker"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),

View file

@ -193,6 +193,10 @@ function formatOptions(config: FormatConfig) {
[LEADER_TOKEN]: leaderDisplay(config),
},
keyNameAliases: {
up: "↑",
down: "↓",
left: "←",
right: "→",
pageup: "pgup",
pagedown: "pgdn",
delete: "del",

View file

@ -82,16 +82,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
const current = store.history.at(store.index)
if (!current) return undefined
if (current.text !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
if (Math.abs(next) > store.history.length) return
if (next > 0) return
draft.index = next
}),
)
if (store.index === 0) return emptyPrompt()
return store.history.at(store.index)
const next = store.index + direction
if (Math.abs(next) > store.history.length || next > 0) return
setStore("index", next)
if (next === 0) return emptyPrompt()
return store.history.at(next)
},
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))

View file

@ -86,6 +86,12 @@ test("resolves a session move keybind", () => {
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
})
test("opens the subagent picker with down", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
const config = resolve({}, { terminalSuspend: false })

View file

@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
import {
formatKeySequence,
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "../src/keymap"
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
const keybinds = TuiKeybind.parse(input)
@ -63,6 +69,47 @@ test("legacy page key aliases compile as page keys", async () => {
}
})
test("formats navigation keys as arrows", async () => {
const shortcuts: Record<string, string> = {}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createResolvedKeymapConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
const offLayer = keymap.registerLayer({
bindings: config.keybinds.gather("test.arrows", commands),
})
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
commands.forEach((command) => {
shortcuts[command] = formatKeySequence(bindings.get(command)?.[0]?.sequence, config)
})
onCleanup(() => {
offLayer()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(shortcuts).toEqual({
"session.parent": "↑",
"session.child.first": "↓",
"session.child.previous": "←",
"session.child.next": "→",
})
} finally {
app.renderer.destroy()
}
})
test("mode-less bindings stay active when opencode mode changes", async () => {
const counts: Record<string, Record<string, number>> = {}

View file

@ -0,0 +1,38 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { TuiPathsProvider } from "../../src/context/runtime"
import { PromptHistoryProvider, usePromptHistory } from "../../src/prompt/history"
import { tmpdir } from "../fixture/fixture"
test("down rejects at the newest history item with an empty prompt", async () => {
await using tmp = await tmpdir()
const state = path.join(tmp.path, "state")
await mkdir(state, { recursive: true })
let history: ReturnType<typeof usePromptHistory>
function Consumer() {
history = usePromptHistory()
return <box />
}
const app = await testRender(() => (
<TuiPathsProvider value={{ cwd: tmp.path, home: tmp.path, state, worktree: tmp.path }}>
<PromptHistoryProvider>
<Consumer />
</PromptHistoryProvider>
</TuiPathsProvider>
))
try {
await app.renderOnce()
history!.append({ text: "previous", files: [], agents: [], pasted: [] })
expect(history!.move(1, "")).toBeUndefined()
expect(history!.move(-1, "")?.text).toBe("previous")
expect(history!.move(1, "previous")?.text).toBe("")
} finally {
app.renderer.destroy()
}
})