diff --git a/packages/cli/bunfig.toml b/packages/cli/bunfig.toml index 7693482f3b..b16283cb5b 100644 --- a/packages/cli/bunfig.toml +++ b/packages/cli/bunfig.toml @@ -1 +1,4 @@ preload = ["@opentui/solid/preload"] + +[test] +preload = ["@opentui/solid/preload"] diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 4c6214d9a8..7d30159126 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -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: [ diff --git a/packages/cli/test/footer-keymap.test.tsx b/packages/cli/test/footer-keymap.test.tsx new file mode 100644 index 0000000000..d1f7fa45b3 --- /dev/null +++ b/packages/cli/test/footer-keymap.test.tsx @@ -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({ + phase: "idle", + status: "", + queue: 0, + model: "gpt-5", + duration: "", + usage: "", + first: false, + interrupt: 0, + exit: 0, + }) + const [view] = createSignal({ type: "prompt" }) + const [subagents] = createSignal({ + 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 ( + []} + 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(() => , { 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() + } +}) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index ce4bcae3be..eef7ff3144 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) { 0}> + + {(shortcut) => {shortcut()} } + {(label) => {label()}} diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0f97f8277d..0ebc2f3b73 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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("q", "Manage queued prompts"), - session_child_first: keybind("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"), diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index 8d0aefc8f5..213a32104d 100644 --- a/packages/tui/src/keymap.tsx +++ b/packages/tui/src/keymap.tsx @@ -193,6 +193,10 @@ function formatOptions(config: FormatConfig) { [LEADER_TOKEN]: leaderDisplay(config), }, keyNameAliases: { + up: "↑", + down: "↓", + left: "←", + right: "→", pageup: "pgup", pagedown: "pgdn", delete: "del", diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 7b01a2e9dc..70305bedb6 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -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)) diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 82d7cf9338..56c7df66eb 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -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 }) diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index b0eed30812..fb3b685dbd 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -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 = {} + + 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 ( + + + + ) + } + + const app = await testRender(() => ) + 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> = {} diff --git a/packages/tui/test/prompt/history-provider.test.tsx b/packages/tui/test/prompt/history-provider.test.tsx new file mode 100644 index 0000000000..a49d45b932 --- /dev/null +++ b/packages/tui/test/prompt/history-provider.test.tsx @@ -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 + + function Consumer() { + history = usePromptHistory() + return + } + + const app = await testRender(() => ( + + + + + + )) + 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() + } +})