feat(tui): open subagents with down
This commit is contained in:
parent
a4a948316b
commit
6ce62bd84a
10 changed files with 241 additions and 21 deletions
|
|
@ -1 +1,4 @@
|
|||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
|
|
|||
|
|
@ -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
packages/cli/test/footer-keymap.test.tsx
Normal file
106
packages/cli/test/footer-keymap.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -193,6 +193,10 @@ function formatOptions(config: FormatConfig) {
|
|||
[LEADER_TOKEN]: leaderDisplay(config),
|
||||
},
|
||||
keyNameAliases: {
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
pageup: "pgup",
|
||||
pagedown: "pgdn",
|
||||
delete: "del",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
packages/tui/test/prompt/history-provider.test.tsx
Normal file
38
packages/tui/test/prompt/history-provider.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue