feat(tui): open subagents with down
This commit is contained in:
@@ -1 +1,4 @@
|
|||||||
preload = ["@opentui/solid/preload"]
|
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
|
if (!area || area.isDestroyed) return false
|
||||||
|
|
||||||
const endOffset = Bun.stringWidth(area.plainText)
|
const endOffset = Bun.stringWidth(area.plainText)
|
||||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
if (dir === -1) {
|
||||||
|
if (area.cursorOffset === 0) return false
|
||||||
|
if (area.visualCursor.visualRow === 0) {
|
||||||
area.cursorOffset = 0
|
area.cursorOffset = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
area.moveCursorUp()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const end =
|
const end =
|
||||||
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
||||||
? area.height - 1
|
? area.height - 1
|
||||||
: Math.max(0, (area.virtualLineCount ?? 1) - 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
|
area.cursorOffset = endOffset
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
area.moveCursorDown()
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestExit = () => {
|
const requestExit = () => {
|
||||||
@@ -1037,6 +1044,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
|
priority: 1,
|
||||||
mode: OPENCODE_BASE_MODE,
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
|
|||||||
@@ -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 keymap = useOpencodeKeymap()
|
||||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||||
|
const liveWorkShortcut = useCommandShortcut("session.child.first")
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -864,6 +865,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
return {
|
return {
|
||||||
|
priority: 1,
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: (() => {
|
enabled: (() => {
|
||||||
cursorVersion()
|
cursorVersion()
|
||||||
@@ -876,8 +878,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (input.cursorOffset !== 0) {
|
if (input.cursorOffset !== 0) {
|
||||||
if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
if (input.scrollY + input.visualCursor.visualRow === 0) {
|
||||||
return false
|
input.cursorOffset = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.moveCursorUp()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = history.move(-1, input.plainText)
|
const item = history.move(-1, input.plainText)
|
||||||
@@ -896,6 +902,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
return {
|
return {
|
||||||
|
priority: 1,
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: (() => {
|
enabled: (() => {
|
||||||
cursorVersion()
|
cursorVersion()
|
||||||
@@ -911,9 +918,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (
|
if (
|
||||||
input.scrollY + input.visualCursor.visualRow ===
|
input.scrollY + input.visualCursor.visualRow ===
|
||||||
Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
|
Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
|
||||||
)
|
) {
|
||||||
input.cursorOffset = input.plainText.length
|
input.cursorOffset = input.plainText.length
|
||||||
return false
|
return
|
||||||
|
}
|
||||||
|
input.moveCursorDown()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = history.move(1, input.plainText)
|
const item = history.move(1, input.plainText)
|
||||||
@@ -1608,6 +1618,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
||||||
<text fg={theme.textMuted} wrapMode="none">
|
<text fg={theme.textMuted} wrapMode="none">
|
||||||
|
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
|
||||||
|
{(shortcut) => <span style={{ fg: theme.text }}>{shortcut()} </span>}
|
||||||
|
</Show>
|
||||||
<Show when={subagentStatusLabel()}>
|
<Show when={subagentStatusLabel()}>
|
||||||
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
|
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const Definitions = {
|
|||||||
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
|
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
|
||||||
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
|
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
|
||||||
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
|
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: keybind("right", "Go to next child session"),
|
||||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||||
session_parent: keybind("up", "Go to parent session"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ function formatOptions(config: FormatConfig) {
|
|||||||
[LEADER_TOKEN]: leaderDisplay(config),
|
[LEADER_TOKEN]: leaderDisplay(config),
|
||||||
},
|
},
|
||||||
keyNameAliases: {
|
keyNameAliases: {
|
||||||
|
up: "↑",
|
||||||
|
down: "↓",
|
||||||
|
left: "←",
|
||||||
|
right: "→",
|
||||||
pageup: "pgup",
|
pageup: "pgup",
|
||||||
pagedown: "pgdn",
|
pagedown: "pgdn",
|
||||||
delete: "del",
|
delete: "del",
|
||||||
|
|||||||
@@ -82,16 +82,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
|||||||
const current = store.history.at(store.index)
|
const current = store.history.at(store.index)
|
||||||
if (!current) return undefined
|
if (!current) return undefined
|
||||||
if (current.text !== input && input.length) return
|
if (current.text !== input && input.length) return
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
const next = store.index + direction
|
const next = store.index + direction
|
||||||
if (Math.abs(next) > store.history.length) return
|
if (Math.abs(next) > store.history.length || next > 0) return
|
||||||
if (next > 0) return
|
setStore("index", next)
|
||||||
draft.index = next
|
if (next === 0) return emptyPrompt()
|
||||||
}),
|
return store.history.at(next)
|
||||||
)
|
|
||||||
if (store.index === 0) return emptyPrompt()
|
|
||||||
return store.history.at(store.index)
|
|
||||||
},
|
},
|
||||||
append(item: PromptInfo) {
|
append(item: PromptInfo) {
|
||||||
const entry = structuredClone(unwrap(item))
|
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" }])
|
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", () => {
|
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||||
const config = resolve({}, { terminalSuspend: false })
|
const config = resolve({}, { terminalSuspend: false })
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
import { TuiKeybind } from "../src/config/keybind"
|
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 = {}) {
|
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
||||||
const keybinds = TuiKeybind.parse(input)
|
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 () => {
|
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||||
const counts: Record<string, Record<string, number>> = {}
|
const counts: Record<string, Record<string, number>> = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user