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

@ -1 +1,4 @@
preload = ["@opentui/solid/preload"]
[test]
preload = ["@opentui/solid/preload"]

View file

@ -778,19 +778,26 @@ export function createPromptState(input: PromptInput): PromptState {
if (!area || area.isDestroyed) return false
const endOffset = Bun.stringWidth(area.plainText)
if (dir === -1 && area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
if (dir === -1) {
if (area.cursorOffset === 0) return false
if (area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
return
}
area.moveCursorUp()
return
}
const end =
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
? area.height - 1
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
if (dir === 1 && area.visualCursor.visualRow === end) {
if (area.cursorOffset === endOffset) return false
if (area.visualCursor.visualRow === end) {
area.cursorOffset = endOffset
return
}
return false
area.moveCursorDown()
}
const requestExit = () => {
@ -1037,6 +1044,7 @@ export function createPromptState(input: PromptInput): PromptState {
}))
useBindings(() => ({
priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [

View file

@ -0,0 +1,106 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { resolve } from "@opencode-ai/tui/config"
import { expect, test } from "bun:test"
import { createComponent, createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
test("down opens subagents from an empty prompt", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5",
duration: "",
usage: "",
first: false,
interrupt: 0,
exit: 0,
})
const [view] = createSignal<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>({
tabs: [
{
sessionID: "subagent-1",
partID: "part-1",
callID: "call-1",
label: "Explore",
description: "Inspect the keymap",
status: "running",
lastUpdatedAt: 1,
},
],
details: {},
permissions: [],
questions: [],
})
const config = resolve(
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return createComponent(OpencodeKeymapProvider, {
keymap,
get children() {
return (
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
try {
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
app.mockInput.pressArrow("down")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Select subagent")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})

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()
}
})