Ver Fonte

feat(tui): open subagents with down

Dax Raad há 1 mês atrás
pai
commit
6ce62bd84a

+ 3 - 0
packages/cli/bunfig.toml

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

+ 13 - 5
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: [

+ 106 - 0
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<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()
+  }
+})

+ 17 - 4
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) {
                 <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>

+ 1 - 1
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("<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"),

+ 4 - 0
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",

+ 5 - 10
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))

+ 6 - 0
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 })
 

+ 48 - 1
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<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>> = {}
 

+ 38 - 0
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<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()
+  }
+})