tui(run): use keymap instead of raw key events (#30077)

Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
This commit is contained in:
Simon Klee
2026-05-31 10:58:12 +02:00
committed by GitHub
parent a291967206
commit e8dd8f7fe0
19 changed files with 760 additions and 891 deletions

View File

@@ -1,8 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
import {
RUN_COMMAND_PANEL_ROWS,
RUN_SUBAGENT_PANEL_ROWS,
@@ -15,7 +17,6 @@ import { RunFooterView } from "@/cli/cmd/run/footer.view"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type {
FooterKeybinds,
FooterState,
FooterSubagentState,
FooterSubagentTab,
@@ -23,26 +24,14 @@ import type {
RunCommand,
RunInput,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "@/cli/cmd/run/types"
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
import { RejectField } from "@/cli/cmd/run/footer.permission"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
const keybinds: FooterKeybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t"),
interrupt: bindings("escape"),
historyPrevious: bindings("up"),
historyNext: bindings("down"),
inputClear: bindings("ctrl+c"),
inputSubmit: bindings("return"),
inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
}
const tuiConfig = createTuiResolvedConfig()
function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
return {
@@ -143,6 +132,90 @@ function subagent(input: {
} satisfies FooterSubagentTab
}
function footerState(input: Partial<FooterState> = {}) {
return createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5",
duration: "",
usage: "",
first: false,
interrupt: 0,
exit: 0,
...input,
})[0]
}
async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => void } = {}) {
const [view] = createSignal<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
const state = footerState()
const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return (
<OpencodeKeymapProvider keymap={keymap}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
resources={() => []}
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={input.onCycle ?? (() => { })}
onInterrupt={() => false}
onInputClear={() => { }}
onExit={() => { }}
onModelSelect={() => { }}
onVariantSelect={() => { }}
onRows={() => { }}
onLayout={() => { }}
onStatus={() => { }}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={8}>
<Harness />
</box>
),
{ width: 100, height: 8, kittyKeyboard: true },
)
return {
...app,
cleanup() {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
offKeymap = undefined
app.renderer.destroy()
},
}
}
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
@@ -204,15 +277,15 @@ test("direct command panel renders grouped command palette", async () => {
commands={commands}
subagents={subagents}
variants={variants}
keybinds={keybinds}
onClose={() => {}}
onModel={() => {}}
onSubagent={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
variantCycle="ctrl+t"
onClose={() => { }}
onModel={() => { }}
onSubagent={() => { }}
onVariant={() => { }}
onVariantCycle={() => { }}
onCommand={() => { }}
onNew={() => { }}
onExit={() => { }}
/>
</box>
),
@@ -262,15 +335,15 @@ test("direct command panel shows subagent entry when available", async () => {
commands={commands}
subagents={subagents}
variants={variants}
keybinds={keybinds}
onClose={() => {}}
onModel={() => {}}
onSubagent={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
variantCycle="ctrl+t"
onClose={() => { }}
onModel={() => { }}
onSubagent={() => { }}
onVariant={() => { }}
onVariantCycle={() => { }}
onCommand={() => { }}
onNew={() => { }}
onExit={() => { }}
/>
</box>
),
@@ -306,8 +379,8 @@ test("direct subagent panel renders active subagents", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
tabs={tabs}
current={current}
onClose={() => {}}
onSelect={() => {}}
onClose={() => { }}
onSelect={() => { }}
onRows={(value) => {
rows = value
}}
@@ -334,6 +407,61 @@ test("direct subagent panel renders active subagents", async () => {
}
})
// OpenTUI currently segfaults when the full footer view suite creates several
// keymap-backed test renderers in one process. Re-enable after the runtime fix.
test.skip("direct footer opens command panel through keymap binding", async () => {
const app = await renderFooter()
try {
await app.renderOnce()
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Commands")
} finally {
app.cleanup()
}
})
test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
const calls: string[] = []
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "<leader>t" } }),
onCycle: () => calls.push("cycle"),
})
try {
await app.renderOnce()
app.mockInput.pressKey("t")
expect(calls).toEqual([])
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("t")
expect(calls).toEqual(["cycle"])
} finally {
app.cleanup()
}
})
test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
const calls: string[] = []
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "<leader>t" } }),
onCycle: () => calls.push("cycle"),
})
try {
await app.renderOnce()
app.mockInput.pressKey("t")
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("t")
expect(calls).toEqual([])
} finally {
app.cleanup()
}
})
test("direct footer shows subagent indicator while prompt is running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
@@ -353,10 +481,14 @@ test("direct footer shows subagent indicator while prompt is running", async ()
permissions: [],
questions: [],
})
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const app = await testRender(
() => (
<box width={100} height={8}>
return (
<OpencodeKeymapProvider keymap={keymap}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
@@ -371,22 +503,30 @@ test("direct footer shows subagent indicator while prompt is running", async ()
view={view}
subagent={subagents}
theme={RUN_THEME_FALLBACK}
keybinds={keybinds}
tuiConfig={tuiConfig}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onPermissionReply={() => { }}
onQuestionReply={() => { }}
onQuestionReject={() => { }}
onCycle={() => { }}
onInterrupt={() => false}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onInputClear={() => { }}
onExit={() => { }}
onModelSelect={() => { }}
onVariantSelect={() => { }}
onRows={() => { }}
onLayout={() => { }}
onStatus={() => { }}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={8}>
<Harness />
</box>
),
{
@@ -399,6 +539,9 @@ test("direct footer shows subagent indicator while prompt is running", async ()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
@@ -429,7 +572,7 @@ test("direct question body separates single-select checkmark from label", async
onReply={(input) => {
replies.push(input)
}}
onReject={() => {}}
onReject={() => { }}
/>
</box>
),
@@ -450,6 +593,120 @@ test("direct question body separates single-select checkmark from label", async
}
})
test("direct custom answer submits through keymap return binding", async () => {
const question = {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Which answer should I use?",
header: "Answer",
options: [{ label: "Provided", description: "Use the listed answer." }],
custom: true,
},
],
} satisfies QuestionRequest
const questions: unknown[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<RunQuestionBody
request={question}
theme={RUN_THEME_FALLBACK.footer}
onReply={(input) => {
questions.push(input)
}}
onReject={() => { }}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={18}>
<Harness />
</box>
),
{ width: 100, height: 18, kittyKeyboard: true },
)
try {
await app.renderOnce()
app.mockInput.pressKey("2")
await app.renderOnce()
"typed".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(questions).toEqual([{ requestID: "question-1", answers: [["typed"]] }])
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
test("direct permission rejection submits through keymap return binding", async () => {
let text = ""
const submits: string[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<RejectField
theme={RUN_THEME_FALLBACK.footer}
text=""
disabled={false}
onChange={(input) => {
text = input
}}
onConfirm={() => {
submits.push(text)
}}
onCancel={() => { }}
/>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={18}>
<Harness />
</box>
),
{ width: 100, height: 18, kittyKeyboard: true },
)
try {
await app.renderOnce()
"retry".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
expect(app.captureCharFrame()).toContain("retry")
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual(["retry"])
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
test("direct model panel renders current model selector", async () => {
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
@@ -461,8 +718,8 @@ test("direct model panel renders current model selector", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
providers={providers}
current={current}
onClose={() => {}}
onSelect={() => {}}
onClose={() => { }}
onSelect={() => { }}
/>
</box>
),
@@ -500,8 +757,8 @@ test("direct variant panel renders current variant selector", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
variants={variants}
current={current}
onClose={() => {}}
onSelect={() => {}}
onClose={() => { }}
onSelect={() => { }}
/>
</box>
),

View File

@@ -7,32 +7,10 @@ import {
isNewCommand,
mentionTriggerIndex,
movePromptHistory,
printableBinding,
promptCycle,
promptHit,
promptInfo,
promptKeys,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types"
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
const keybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t", "<leader>t"),
interrupt: bindings("escape"),
historyPrevious: bindings("up"),
historyNext: bindings("down"),
inputClear: bindings("ctrl+c"),
inputSubmit: bindings("return"),
inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
}
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
}
@@ -141,39 +119,6 @@ describe("run prompt shared", () => {
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
})
test("handles direct and leader-based variant cycling", () => {
const keys = promptKeys(keybinds)
expect(promptHit(keys.clear, promptInfo({ name: "c", ctrl: true }))).toBe(true)
expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: true,
clear: false,
cycle: false,
consume: true,
})
expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: true,
cycle: true,
consume: true,
})
expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: false,
cycle: true,
consume: true,
})
})
test("prints bindings with leader substitution and esc normalization", () => {
expect(printableBinding(keybinds.variantCycle.slice(1), "ctrl+x")).toBe("ctrl+x t")
expect(printableBinding(keybinds.interrupt, "ctrl+x")).toBe("esc")
expect(printableBinding([], "ctrl+x")).toBe("")
})
test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true)

View File

@@ -1,8 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
@@ -111,35 +110,44 @@ describe("run runtime boot", () => {
}),
)
const result = await resolveFooterKeybinds()
const result = await resolveRunTuiConfig()
expect(result.leader).toBe("ctrl+g")
expect(result.leaderTimeout).toBe(2000)
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
expect(formatBindings(result.historyNext, result.leader)).toBe("j")
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
expect(result.leader_timeout).toBe(2000)
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
})
test("falls back to default keybinds when config load fails", async () => {
test("falls back to default tui keymap config when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
const result = await resolveFooterKeybinds()
const result = await resolveRunTuiConfig()
expect(result.leader).toBe("ctrl+x")
expect(result.leaderTimeout).toBe(2000)
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t")
expect(formatBindings(result.interrupt, result.leader)).toBe("esc")
expect(formatBindings(result.historyPrevious, result.leader)).toBe("up")
expect(formatBindings(result.historyNext, result.leader)).toBe("down")
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c")
expect(formatBindings(result.inputSubmit, result.leader)).toBe("return")
expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j")
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
expect(result.leader_timeout).toBe(2000)
expect(result.diff_style).toBe("auto")
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
})
test("preserves disabled leader from resolved tui config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")).toEqual([])
})
test("reads diff style and falls back to auto", async () => {