mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 01:39:36 +00:00
tui(run): use keymap instead of raw key events (#30077)
Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
This commit is contained in:
parent
a291967206
commit
e8dd8f7fe0
19 changed files with 760 additions and 891 deletions
30
bun.lock
30
bun.lock
|
|
@ -611,9 +611,9 @@
|
|||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16",
|
||||
"@opentui/core": ">=0.3.0",
|
||||
"@opentui/keymap": ">=0.3.0",
|
||||
"@opentui/solid": ">=0.3.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
|
|
@ -862,9 +862,9 @@
|
|||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"@opentui/core": "0.3.0",
|
||||
"@opentui/keymap": "0.3.0",
|
||||
"@opentui/solid": "0.3.0",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
|
|
@ -1758,23 +1758,23 @@
|
|||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
|
||||
"@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.0", "", { "dependencies": { "@opentui/core": "0.3.0" }, "peerDependencies": { "@opentui/react": "0.3.0", "@opentui/solid": "0.3.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lJN57DanKujy3u0IhfSMCShvXIobRjhprdkrdM3brQoX6wxk7gTFE8fTCCz9z1nINkXNsKHQ6grZO1dsT/0mzA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@
|
|||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"@opentui/core": "0.3.0",
|
||||
"@opentui/keymap": "0.3.0",
|
||||
"@opentui/solid": "0.3.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { useKeyboard, type JSX } from "@opentui/solid"
|
|||
import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { formatBindings } from "./keymap.shared"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||
import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||
|
||||
type PanelEntry = RunFooterMenuItem & {
|
||||
category: string
|
||||
|
|
@ -296,7 +295,7 @@ export function RunCommandMenuBody(props: {
|
|||
commands: Accessor<RunCommand[] | undefined>
|
||||
subagents: Accessor<FooterSubagentTab[]>
|
||||
variants: Accessor<string[]>
|
||||
keybinds: FooterKeybinds
|
||||
variantCycle: string
|
||||
onClose: () => void
|
||||
onModel: () => void
|
||||
onSubagent: () => void
|
||||
|
|
@ -334,7 +333,7 @@ export function RunCommandMenuBody(props: {
|
|||
action: "variant.cycle",
|
||||
category: "Suggested",
|
||||
display: "Variant cycle",
|
||||
footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader),
|
||||
footer: props.variantCycle,
|
||||
keywords: "variant cycle",
|
||||
},
|
||||
...(props.variants().length > 0
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ function buttons(
|
|||
)
|
||||
}
|
||||
|
||||
function RejectField(props: {
|
||||
/** @internal Exported to test managed textarea submission without permission navigation. */
|
||||
export function RejectField(props: {
|
||||
theme: RunFooterTheme
|
||||
text: string
|
||||
disabled: boolean
|
||||
|
|
@ -107,6 +108,7 @@ function RejectField(props: {
|
|||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!props.disabled}
|
||||
onSubmit={props.onConfirm}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
|
|
@ -119,11 +121,6 @@ function RejectField(props: {
|
|||
props.onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) {
|
||||
event.preventDefault()
|
||||
props.onConfirm()
|
||||
}
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// Prompt textarea component and its state machine for direct interactive mode.
|
||||
//
|
||||
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
||||
// and `@` autocomplete for files, subagents, and MCP resources.
|
||||
// createPromptState() wires keymap command layers, history navigation, and
|
||||
// `@` autocomplete for files, subagents, and MCP resources.
|
||||
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
||||
// while the footer view renders the current menu state below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { pathToFileURL } from "bun"
|
||||
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
|
|
@ -20,15 +20,12 @@ import {
|
|||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
promptCycle,
|
||||
promptHit,
|
||||
promptInfo,
|
||||
promptKeys,
|
||||
pushPromptHistory,
|
||||
} from "./prompt.shared"
|
||||
import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterKeybinds, FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource } from "./types"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
|
|
@ -69,7 +66,7 @@ type PromptInput = {
|
|||
subagents: Accessor<number>
|
||||
resources: Accessor<RunResource[]>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
keybinds: FooterKeybinds
|
||||
tuiConfig: RunTuiConfig
|
||||
state: Accessor<FooterState>
|
||||
view: Accessor<string>
|
||||
prompt: Accessor<boolean>
|
||||
|
|
@ -89,7 +86,6 @@ type PromptInput = {
|
|||
|
||||
export type PromptState = {
|
||||
placeholder: Accessor<StyledText | string>
|
||||
bindings: Accessor<KeyBinding[]>
|
||||
shell: Accessor<boolean>
|
||||
visible: Accessor<boolean>
|
||||
options: Accessor<PromptOption[]>
|
||||
|
|
@ -199,7 +195,6 @@ export function hintFlags(width: number) {
|
|||
export function RunPromptBody(props: {
|
||||
theme: () => RunFooterTheme
|
||||
placeholder: () => StyledText | string
|
||||
bindings: () => KeyBinding[]
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
|
|
@ -263,7 +258,6 @@ export function RunPromptBody(props: {
|
|||
backgroundColor={props.theme().surface}
|
||||
focusedBackgroundColor={props.theme().surface}
|
||||
cursorColor={props.theme().text}
|
||||
keyBindings={props.bindings()}
|
||||
onSubmit={props.onSubmit}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onPaste={() => {
|
||||
|
|
@ -280,8 +274,6 @@ export function RunPromptBody(props: {
|
|||
}
|
||||
|
||||
export function createPromptState(input: PromptInput): PromptState {
|
||||
const keys = createMemo(() => promptKeys(input.keybinds))
|
||||
const bindings = createMemo(() => keys().bindings)
|
||||
const [shell, setShell] = createSignal(false)
|
||||
const placeholder = createMemo(() => {
|
||||
if (shell()) {
|
||||
|
|
@ -301,8 +293,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
let draft: RunPrompt = { text: "", parts: [] }
|
||||
let stash: RunPrompt = { text: "", parts: [] }
|
||||
let area: TextareaRenderable | undefined
|
||||
let leader = false
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
let tick = false
|
||||
let prev = input.view()
|
||||
let type = 0
|
||||
|
|
@ -461,24 +451,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
leader = false
|
||||
if (!timeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
clear()
|
||||
leader = true
|
||||
timeout = setTimeout(() => {
|
||||
clear()
|
||||
}, input.keybinds.leaderTimeout)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
setMode(false)
|
||||
setQuery("")
|
||||
|
|
@ -742,7 +714,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const move = (dir: -1 | 1, event: KeyEvent) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (history.index === null && dir === -1) {
|
||||
|
|
@ -751,7 +723,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
history = next.state
|
||||
|
|
@ -759,28 +731,27 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
|
||||
restore(value, next.cursor)
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
const cycle = (event: KeyEvent): boolean => {
|
||||
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
||||
if (!next.consume) {
|
||||
return false
|
||||
const historyCommand = (dir: -1 | 1, event: KeyEvent) => {
|
||||
if (move(dir, event)) return
|
||||
if (!area || area.isDestroyed) return false
|
||||
|
||||
const endOffset = Bun.stringWidth(area.plainText)
|
||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
}
|
||||
|
||||
if (next.clear) {
|
||||
clear()
|
||||
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) {
|
||||
area.cursorOffset = endOffset
|
||||
}
|
||||
|
||||
if (next.arm) {
|
||||
arm()
|
||||
}
|
||||
|
||||
if (next.cycle) {
|
||||
input.onCycle()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
const requestExit = () => {
|
||||
|
|
@ -912,178 +883,190 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
refresh()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyEvent) => {
|
||||
const key = promptInfo(event)
|
||||
if (visible()) {
|
||||
const name = event.name.toLowerCase()
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
menu.move(-1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
menu.move(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "escape") {
|
||||
event.preventDefault()
|
||||
cancelAutocomplete()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
if (mode() === "slash" && options().length === 0) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "tab") {
|
||||
if (mode() === "slash" && options().length === 0) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const item = options()[menu.selected()]
|
||||
if (item?.kind === "mention" && item.directory) {
|
||||
expand()
|
||||
return
|
||||
}
|
||||
|
||||
select()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === "!" &&
|
||||
!shell() &&
|
||||
!event.ctrl &&
|
||||
!event.meta &&
|
||||
!event.super &&
|
||||
area &&
|
||||
!area.isDestroyed &&
|
||||
area.cursorOffset === 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
setShellMode(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (shell() && !visible()) {
|
||||
if (key.name === "escape") {
|
||||
event.preventDefault()
|
||||
setShellMode(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) {
|
||||
event.preventDefault()
|
||||
setShellMode(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === "down" &&
|
||||
!visible() &&
|
||||
!event.ctrl &&
|
||||
!event.meta &&
|
||||
!event.shift &&
|
||||
!event.super &&
|
||||
area &&
|
||||
!area.isDestroyed &&
|
||||
area.plainText.length === 0 &&
|
||||
input.subagents() > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
input.onSubagentMenu?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (promptHit(keys().clear, key)) {
|
||||
const handled = requestExit()
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (promptHit(keys().interrupts, key)) {
|
||||
if (input.onInterrupt()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (cycle(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const up = promptHit(keys().previous, key)
|
||||
const down = promptHit(keys().next, key)
|
||||
if (!up && !down) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const dir = up ? -1 : 1
|
||||
const endOffset = Bun.stringWidth(area.plainText)
|
||||
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) {
|
||||
move(dir, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
}
|
||||
|
||||
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) {
|
||||
area.cursorOffset = endOffset
|
||||
}
|
||||
const baseBindingsEnabled = () => {
|
||||
const current = input.view()
|
||||
if (current === "command") return false
|
||||
if (current === "model") return false
|
||||
if (current === "variant") return false
|
||||
if (current === "subagent-menu") return false
|
||||
return true
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (input.prompt()) {
|
||||
return
|
||||
}
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: baseBindingsEnabled(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.clear",
|
||||
title: "Clear prompt or exit",
|
||||
category: "Prompt",
|
||||
run() {
|
||||
if (requestExit()) return
|
||||
return false
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
|
||||
}))
|
||||
|
||||
if (
|
||||
input.view() === "command" ||
|
||||
input.view() === "model" ||
|
||||
input.view() === "variant" ||
|
||||
input.view() === "subagent-menu"
|
||||
) {
|
||||
return
|
||||
}
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt(),
|
||||
commands: [
|
||||
{
|
||||
name: "session.interrupt",
|
||||
title: "Interrupt session",
|
||||
category: "Session",
|
||||
run() {
|
||||
if (input.onInterrupt()) return
|
||||
return false
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
|
||||
}))
|
||||
|
||||
if (promptHit(keys().clear, promptInfo(event))) {
|
||||
const handled = requestExit()
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt() && !visible(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.history.previous",
|
||||
title: "Previous prompt history",
|
||||
category: "Prompt",
|
||||
run(ctx: { event: KeyEvent }) {
|
||||
return historyCommand(-1, ctx.event)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.history.next",
|
||||
title: "Next prompt history",
|
||||
category: "Prompt",
|
||||
run(ctx: { event: KeyEvent }) {
|
||||
return historyCommand(1, ctx.event)
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
...input.tuiConfig.keybinds.get("prompt.history.previous"),
|
||||
...input.tuiConfig.keybinds.get("prompt.history.next"),
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt() && !visible(),
|
||||
bindings: [
|
||||
{
|
||||
key: "down",
|
||||
desc: "View subagents",
|
||||
group: "Prompt",
|
||||
cmd() {
|
||||
if (!area || area.isDestroyed) return false
|
||||
if (area.plainText.length !== 0) return false
|
||||
if (input.subagents() === 0) return false
|
||||
input.onSubagentMenu?.()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "!",
|
||||
desc: "Shell mode",
|
||||
group: "Prompt",
|
||||
cmd() {
|
||||
if (shell()) return false
|
||||
if (!area || area.isDestroyed) return false
|
||||
if (area.cursorOffset !== 0) return false
|
||||
setShellMode(true)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt() && shell() && !visible(),
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Exit shell mode",
|
||||
group: "Prompt",
|
||||
cmd: () => setShellMode(false),
|
||||
},
|
||||
{
|
||||
key: "backspace",
|
||||
desc: "Exit shell mode",
|
||||
group: "Prompt",
|
||||
cmd() {
|
||||
if (!area || area.isDestroyed) return false
|
||||
if (area.cursorOffset !== 0) return false
|
||||
setShellMode(false)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt() && visible(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.autocomplete.prev",
|
||||
title: "Previous autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run: () => menu.move(-1),
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.next",
|
||||
title: "Next autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run: () => menu.move(1),
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.hide",
|
||||
title: "Hide autocomplete",
|
||||
category: "Autocomplete",
|
||||
run: cancelAutocomplete,
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.select",
|
||||
title: "Select autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
if (mode() === "slash" && options().length === 0) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
select()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.complete",
|
||||
title: "Complete autocomplete item",
|
||||
category: "Autocomplete",
|
||||
run() {
|
||||
if (mode() === "slash" && options().length === 0) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
const item = options()[menu.selected()]
|
||||
if (item?.kind === "mention" && item.directory) {
|
||||
expand()
|
||||
return
|
||||
}
|
||||
select()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: input.tuiConfig.keybinds.gather("run.prompt.autocomplete", [
|
||||
"prompt.autocomplete.prev",
|
||||
"prompt.autocomplete.next",
|
||||
"prompt.autocomplete.hide",
|
||||
"prompt.autocomplete.select",
|
||||
"prompt.autocomplete.complete",
|
||||
]),
|
||||
}))
|
||||
|
||||
const onKeyDown = (_event: KeyEvent) => {}
|
||||
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
|
|
@ -1144,7 +1127,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clear()
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
|
|
@ -1188,7 +1170,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
syncDraft()
|
||||
}
|
||||
|
||||
clear()
|
||||
hide()
|
||||
prev = kind
|
||||
if (kind !== "prompt") {
|
||||
|
|
@ -1202,7 +1183,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
return {
|
||||
placeholder,
|
||||
bindings,
|
||||
shell,
|
||||
visible,
|
||||
options,
|
||||
|
|
|
|||
|
|
@ -177,10 +177,6 @@ export function RunQuestionBody(props: {
|
|||
return
|
||||
}
|
||||
|
||||
if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) {
|
||||
saveCustom()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -496,6 +492,7 @@ export function RunQuestionBody(props: {
|
|||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!disabled()}
|
||||
onSubmit={saveCustom}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed || disabled()) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -24,22 +24,22 @@
|
|||
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
||||
// two-press pattern where the first press shows a hint and the second press
|
||||
// within 5 seconds actually fires the action.
|
||||
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
|
||||
import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
|
||||
import type { Keymap } from "@opentui/keymap"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { RunScrollbackStream } from "./scrollback.surface"
|
||||
import type { RunTheme } from "./theme"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterEvent,
|
||||
FooterKeybinds,
|
||||
FooterPatch,
|
||||
FooterPromptRoute,
|
||||
FooterState,
|
||||
|
|
@ -55,6 +55,7 @@ import type {
|
|||
RunPrompt,
|
||||
RunProvider,
|
||||
RunResource,
|
||||
RunTuiConfig,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
|
|
@ -80,7 +81,8 @@ type RunFooterOptions = {
|
|||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
keybinds: FooterKeybinds
|
||||
keymap: Keymap<Renderable, KeyEvent>
|
||||
tuiConfig: RunTuiConfig
|
||||
diffStyle: RunDiffStyle
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
|
|
@ -195,7 +197,6 @@ export class RunFooter implements FooterApi {
|
|||
private autocomplete = false
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
private requestExitHandler: (() => boolean) | undefined
|
||||
private scrollback: RunScrollbackStream
|
||||
|
||||
|
|
@ -249,7 +250,6 @@ export class RunFooter implements FooterApi {
|
|||
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
||||
}
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
||||
diffStyle: options.diffStyle,
|
||||
wrote: options.wrote,
|
||||
|
|
@ -259,42 +259,48 @@ export class RunFooter implements FooterApi {
|
|||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
const footer = this
|
||||
void render(
|
||||
() =>
|
||||
createComponent(RunFooterView, {
|
||||
directory: options.directory,
|
||||
state: this.state,
|
||||
view: this.view,
|
||||
subagent: this.subagent,
|
||||
findFiles: options.findFiles,
|
||||
agents: this.agents,
|
||||
resources: this.resources,
|
||||
commands: this.commands,
|
||||
providers: this.providers,
|
||||
currentModel: this.currentModel,
|
||||
variants: this.variants,
|
||||
currentVariant: this.currentVariant,
|
||||
theme: options.theme,
|
||||
diffStyle: options.diffStyle,
|
||||
keybinds: options.keybinds,
|
||||
history: options.history,
|
||||
agent: options.agentLabel,
|
||||
onSubmit: this.handlePrompt,
|
||||
onPermissionReply: this.handlePermissionReply,
|
||||
onQuestionReply: this.handleQuestionReply,
|
||||
onQuestionReject: this.handleQuestionReject,
|
||||
onCycle: this.handleCycle,
|
||||
onInterrupt: this.handleInterrupt,
|
||||
onInputClear: this.handleInputClear,
|
||||
onExitRequest: this.handleExit,
|
||||
onRequestExit: this.setRequestExitHandler,
|
||||
onExit: () => this.close(),
|
||||
onModelSelect: this.handleModelSelect,
|
||||
onVariantSelect: this.handleVariantSelect,
|
||||
onRows: this.syncRows,
|
||||
onLayout: this.syncLayout,
|
||||
onStatus: this.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
createComponent(OpencodeKeymapProvider, {
|
||||
keymap: options.keymap,
|
||||
get children() {
|
||||
return createComponent(RunFooterView, {
|
||||
directory: options.directory,
|
||||
state: footer.state,
|
||||
view: footer.view,
|
||||
subagent: footer.subagent,
|
||||
findFiles: options.findFiles,
|
||||
agents: footer.agents,
|
||||
resources: footer.resources,
|
||||
commands: footer.commands,
|
||||
providers: footer.providers,
|
||||
currentModel: footer.currentModel,
|
||||
variants: footer.variants,
|
||||
currentVariant: footer.currentVariant,
|
||||
theme: options.theme,
|
||||
diffStyle: options.diffStyle,
|
||||
tuiConfig: options.tuiConfig,
|
||||
history: options.history,
|
||||
agent: options.agentLabel,
|
||||
onSubmit: footer.handlePrompt,
|
||||
onPermissionReply: footer.handlePermissionReply,
|
||||
onQuestionReply: footer.handleQuestionReply,
|
||||
onQuestionReject: footer.handleQuestionReject,
|
||||
onCycle: footer.handleCycle,
|
||||
onInterrupt: footer.handleInterrupt,
|
||||
onInputClear: footer.handleInputClear,
|
||||
onExitRequest: footer.handleExit,
|
||||
onRequestExit: footer.setRequestExitHandler,
|
||||
onExit: () => footer.close(),
|
||||
onModelSelect: footer.handleModelSelect,
|
||||
onVariantSelect: footer.handleVariantSelect,
|
||||
onRows: footer.syncRows,
|
||||
onLayout: footer.syncLayout,
|
||||
onStatus: footer.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
})
|
||||
},
|
||||
}),
|
||||
this.renderer,
|
||||
).catch(() => {
|
||||
|
|
@ -781,6 +787,13 @@ export class RunFooter implements FooterApi {
|
|||
}, 5000)
|
||||
}
|
||||
|
||||
private interruptHint(): string {
|
||||
const bindings = this.options.keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
|
||||
.get("session.interrupt")
|
||||
return formatKeyBindings(bindings, this.options.tuiConfig) || "esc"
|
||||
}
|
||||
|
||||
private clearExitTimer(): void {
|
||||
if (!this.exitTimeout) {
|
||||
return
|
||||
|
|
@ -815,7 +828,7 @@ export class RunFooter implements FooterApi {
|
|||
|
||||
if (next < 2) {
|
||||
this.armInterruptTimer()
|
||||
this.patch({ status: `${this.interruptHint} again to interrupt` })
|
||||
this.patch({ status: `${this.interruptHint()} again to interrupt` })
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
// All state comes from the parent RunFooter through SolidJS signals.
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
|
|
@ -26,9 +26,14 @@ import { RunFooterSubagentBody } from "./footer.subagent"
|
|||
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunQuestionBody } from "./footer.question"
|
||||
import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared"
|
||||
import {
|
||||
OPENCODE_BASE_MODE,
|
||||
formatKeyBindings,
|
||||
useBindings,
|
||||
useKeymapSelector,
|
||||
type OpenTuiKeymap,
|
||||
} from "@/cli/cmd/tui/keymap"
|
||||
import type {
|
||||
FooterKeybinds,
|
||||
FooterPromptRoute,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
|
|
@ -43,6 +48,7 @@ import type {
|
|||
RunPrompt,
|
||||
RunProvider,
|
||||
RunResource,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
|
||||
|
|
@ -75,7 +81,7 @@ type RunFooterViewProps = {
|
|||
subagent?: () => FooterSubagentState
|
||||
theme?: RunTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
keybinds: FooterKeybinds
|
||||
tuiConfig: RunTuiConfig
|
||||
history?: RunPrompt[]
|
||||
agent: string
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
|
|
@ -149,9 +155,24 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
const current = route()
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
})
|
||||
const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const commandKeys = createMemo(() => promptBindings(props.keybinds.commandList, props.keybinds.leader))
|
||||
const command = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||
formatKeyBindings(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] }).get("command.palette.show"),
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const interrupt = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||
formatKeyBindings(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }).get("session.interrupt"),
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const variantCycle = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||
formatKeyBindings(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
|
|
@ -257,7 +278,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
subagents: () => tabs().length,
|
||||
resources: props.resources,
|
||||
commands: props.commands,
|
||||
keybinds: props.keybinds,
|
||||
tuiConfig: props.tuiConfig,
|
||||
state: props.state,
|
||||
view: promptView,
|
||||
prompt,
|
||||
|
|
@ -285,30 +306,28 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
props.onRequestExit?.(undefined)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
if (active().type !== "prompt") {
|
||||
return
|
||||
}
|
||||
|
||||
if (route().type !== "composer") {
|
||||
return
|
||||
}
|
||||
|
||||
if (composer.visible()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!promptHit(commandKeys(), promptInfo(event))) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
openCommand()
|
||||
})
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
||||
commands: [
|
||||
{
|
||||
name: "command.palette.show",
|
||||
title: "Open command palette",
|
||||
category: "Prompt",
|
||||
run: openCommand,
|
||||
},
|
||||
{
|
||||
name: "variant.cycle",
|
||||
title: "Cycle model variant",
|
||||
category: "Model",
|
||||
run: props.onCycle,
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
...props.tuiConfig.keybinds.get("command.palette.show"),
|
||||
...props.tuiConfig.keybinds.get("variant.cycle"),
|
||||
],
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
const current = route()
|
||||
|
|
@ -407,7 +426,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
<RunPromptBody
|
||||
theme={theme}
|
||||
placeholder={composer.placeholder}
|
||||
bindings={composer.bindings}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onContentChange={composer.onContentChange}
|
||||
|
|
@ -430,7 +448,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
commands={props.commands}
|
||||
subagents={tabs}
|
||||
variants={props.variants}
|
||||
keybinds={props.keybinds}
|
||||
variantCycle={variantCycle()}
|
||||
onClose={closePanel}
|
||||
onModel={openModel}
|
||||
onSubagent={openSubagentMenu}
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
import { KeyEvent } from "@opentui/core"
|
||||
import { Keymap, type Binding, type KeySequencePart } from "@opentui/keymap"
|
||||
import { registerDefaultKeys, registerLeader } from "@opentui/keymap/addons"
|
||||
import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
|
||||
|
||||
type ParsedBindingInput = Pick<Binding, "key" | "event">
|
||||
|
||||
export type ParsedBinding = {
|
||||
sequence: KeySequencePart[]
|
||||
event: "press" | "release"
|
||||
}
|
||||
|
||||
const keyNameAliases = {
|
||||
delete: "del",
|
||||
enter: "return",
|
||||
escape: "esc",
|
||||
pagedown: "pgdn",
|
||||
pageup: "pgup",
|
||||
} as const
|
||||
|
||||
const modifierAliases = {
|
||||
meta: "alt",
|
||||
} as const
|
||||
|
||||
function hostPlatform() {
|
||||
if (process.platform === "darwin") {
|
||||
return "macos" as const
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return "windows" as const
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
return "linux" as const
|
||||
}
|
||||
|
||||
return "unknown" as const
|
||||
}
|
||||
|
||||
function createCommandEvent() {
|
||||
return new KeyEvent({
|
||||
name: "command",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
option: false,
|
||||
sequence: "",
|
||||
number: false,
|
||||
raw: "",
|
||||
eventType: "press",
|
||||
source: "raw",
|
||||
})
|
||||
}
|
||||
|
||||
function createParser(leader: string) {
|
||||
const platform = hostPlatform()
|
||||
const keymap = new Keymap({
|
||||
metadata: {
|
||||
platform,
|
||||
primaryModifier: platform === "macos" ? "super" : platform === "unknown" ? "unknown" : "ctrl",
|
||||
modifiers: {
|
||||
ctrl: "supported",
|
||||
shift: "supported",
|
||||
meta: "supported",
|
||||
super: "unknown",
|
||||
hyper: "unknown",
|
||||
},
|
||||
},
|
||||
rootTarget: {},
|
||||
isDestroyed: false,
|
||||
getFocusedTarget() {
|
||||
return null
|
||||
},
|
||||
getParentTarget(_target) {
|
||||
return null
|
||||
},
|
||||
isTargetDestroyed(_target) {
|
||||
return false
|
||||
},
|
||||
onKeyPress(_listener) {
|
||||
return () => {}
|
||||
},
|
||||
onKeyRelease(_listener) {
|
||||
return () => {}
|
||||
},
|
||||
onFocusChange(_listener) {
|
||||
return () => {}
|
||||
},
|
||||
onTargetDestroy(_target, _listener) {
|
||||
return () => {}
|
||||
},
|
||||
createCommandEvent,
|
||||
})
|
||||
|
||||
const offDefault = registerDefaultKeys(keymap)
|
||||
const offLeader = registerLeader(keymap, { trigger: leader })
|
||||
|
||||
return {
|
||||
keymap,
|
||||
dispose() {
|
||||
offLeader()
|
||||
offDefault()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptions(leader: string) {
|
||||
return {
|
||||
tokenDisplay: {
|
||||
leader,
|
||||
},
|
||||
keyNameAliases,
|
||||
modifierAliases,
|
||||
} as const
|
||||
}
|
||||
|
||||
function splitBinding(binding: ParsedBindingInput) {
|
||||
if (typeof binding.key !== "string" || !binding.key.includes(",")) {
|
||||
return [binding]
|
||||
}
|
||||
|
||||
return binding.key
|
||||
.split(",")
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
.map((key) => ({
|
||||
...binding,
|
||||
key,
|
||||
}))
|
||||
}
|
||||
|
||||
export function parseBindings(bindings: readonly ParsedBindingInput[], leader: string): ParsedBinding[] {
|
||||
const parser = createParser(leader)
|
||||
|
||||
try {
|
||||
return bindings.flatMap((binding) =>
|
||||
splitBinding(binding).map((item) => ({
|
||||
sequence: Array.from(parser.keymap.parseKeySequence(item.key)),
|
||||
event: item.event ?? "press",
|
||||
})),
|
||||
)
|
||||
} finally {
|
||||
parser.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBinding(bindings: readonly ParsedBindingInput[], leader: string) {
|
||||
return formatKeySequence(parseBindings(bindings, leader)[0]?.sequence, formatOptions(leader))
|
||||
}
|
||||
|
||||
export function formatBindings(bindings: readonly ParsedBindingInput[], leader: string) {
|
||||
return formatCommandBindings(parseBindings(bindings, leader), formatOptions(leader))
|
||||
}
|
||||
|
|
@ -1,20 +1,14 @@
|
|||
// Pure state machine for the prompt input.
|
||||
//
|
||||
// Handles keybind parsing, history ring navigation, and the leader-key
|
||||
// sequence for variant cycling. All functions are pure -- they take state
|
||||
// in and return new state out, with no side effects.
|
||||
// Handles history ring navigation and prompt text helpers. All functions are
|
||||
// pure -- they take state in and return new state out, with no side effects.
|
||||
//
|
||||
// The history ring (PromptHistoryState) stores past prompts and tracks
|
||||
// the current browse position. When the user arrows up at cursor offset 0,
|
||||
// the current draft is saved and history begins. Arrowing past the end
|
||||
// restores the draft.
|
||||
//
|
||||
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
|
||||
// arms the leader, second press within the timeout fires the action.
|
||||
import type { KeyBinding } from "@opentui/core"
|
||||
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
|
||||
import { formatBinding, parseBindings } from "./keymap.shared"
|
||||
import type { FooterKeybinds, RunPrompt } from "./types"
|
||||
import type { RunPrompt } from "./types"
|
||||
|
||||
const HISTORY_LIMIT = 200
|
||||
|
||||
|
|
@ -24,36 +18,6 @@ export type PromptHistoryState = {
|
|||
draft: string
|
||||
}
|
||||
|
||||
export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) {
|
||||
return {
|
||||
name: event.name === " " ? "space" : event.name,
|
||||
ctrl: !!event.ctrl,
|
||||
meta: !!event.meta,
|
||||
shift: !!event.shift,
|
||||
super: !!event.super,
|
||||
leader: false,
|
||||
}
|
||||
}
|
||||
|
||||
type PromptInfo = ReturnType<typeof promptInfo>
|
||||
|
||||
export type PromptKeys = {
|
||||
leaders: PromptInfo[]
|
||||
cycles: PromptInfo[]
|
||||
interrupts: PromptInfo[]
|
||||
previous: PromptInfo[]
|
||||
next: PromptInfo[]
|
||||
clear: PromptInfo[]
|
||||
bindings: KeyBinding[]
|
||||
}
|
||||
|
||||
export type PromptCycle = {
|
||||
arm: boolean
|
||||
clear: boolean
|
||||
cycle: boolean
|
||||
consume: boolean
|
||||
}
|
||||
|
||||
export type PromptMove = {
|
||||
state: PromptHistoryState
|
||||
text?: string
|
||||
|
|
@ -73,98 +37,6 @@ export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
|||
return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
|
||||
}
|
||||
|
||||
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
|
||||
if (binding.event !== "press") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const first = binding.sequence[0]
|
||||
const second = binding.sequence[1]
|
||||
|
||||
if (!first) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!second) {
|
||||
return first.patternName || first.tokenName
|
||||
? undefined
|
||||
: {
|
||||
name: first.stroke.name,
|
||||
ctrl: first.stroke.ctrl,
|
||||
meta: first.stroke.meta,
|
||||
shift: first.stroke.shift,
|
||||
super: first.stroke.super,
|
||||
leader: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (binding.sequence.length !== 2 || first.tokenName !== "leader" || second.patternName || second.tokenName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
name: second.stroke.name,
|
||||
ctrl: second.stroke.ctrl,
|
||||
meta: second.stroke.meta,
|
||||
shift: second.stroke.shift,
|
||||
super: second.stroke.super,
|
||||
leader: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function promptBindings(bindings: FooterKeybinds["commandList"], leader: string): PromptInfo[] {
|
||||
return parseBindings(bindings, leader).flatMap((binding) => {
|
||||
const key = promptKey(binding)
|
||||
return key ? [key] : []
|
||||
})
|
||||
}
|
||||
|
||||
function mapInputBindings(
|
||||
bindings: FooterKeybinds["inputSubmit"],
|
||||
leader: string,
|
||||
action: "submit" | "newline",
|
||||
): KeyBinding[] {
|
||||
return promptBindings(bindings, leader).flatMap((key) => {
|
||||
if (key.leader) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: key.name,
|
||||
ctrl: key.ctrl || undefined,
|
||||
meta: key.meta || undefined,
|
||||
shift: key.shift || undefined,
|
||||
super: key.super || undefined,
|
||||
action,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
|
||||
return [
|
||||
...mapInputBindings(keybinds.inputSubmit, keybinds.leader, "submit"),
|
||||
...mapInputBindings(keybinds.inputNewline, keybinds.leader, "newline"),
|
||||
]
|
||||
}
|
||||
|
||||
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
|
||||
return {
|
||||
leaders: promptBindings([{ key: keybinds.leader }], keybinds.leader),
|
||||
cycles: promptBindings(keybinds.variantCycle, keybinds.leader),
|
||||
interrupts: promptBindings(keybinds.interrupt, keybinds.leader),
|
||||
previous: promptBindings(keybinds.historyPrevious, keybinds.leader),
|
||||
next: promptBindings(keybinds.historyNext, keybinds.leader),
|
||||
clear: promptBindings(keybinds.inputClear, keybinds.leader),
|
||||
bindings: textareaBindings(keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
export function printableBinding(bindings: FooterKeybinds["commandList"], leader: string): string {
|
||||
return formatBinding(bindings, leader)
|
||||
}
|
||||
|
||||
export function isExitCommand(input: string): boolean {
|
||||
const text = input.trim().toLowerCase()
|
||||
return text === "/exit" || text === "/quit" || text === ":q"
|
||||
|
|
@ -174,59 +46,6 @@ export function isNewCommand(input: string): boolean {
|
|||
return input.trim().toLowerCase() === "/new"
|
||||
}
|
||||
|
||||
export function promptHit(bindings: PromptInfo[], event: PromptInfo): boolean {
|
||||
return bindings.some(
|
||||
(item) =>
|
||||
item.name === event.name &&
|
||||
item.ctrl === event.ctrl &&
|
||||
item.meta === event.meta &&
|
||||
item.shift === event.shift &&
|
||||
item.super === event.super &&
|
||||
item.leader === event.leader,
|
||||
)
|
||||
}
|
||||
|
||||
export function promptCycle(
|
||||
armed: boolean,
|
||||
event: PromptInfo,
|
||||
leaders: PromptInfo[],
|
||||
cycles: PromptInfo[],
|
||||
): PromptCycle {
|
||||
if (!armed && promptHit(leaders, event)) {
|
||||
return {
|
||||
arm: true,
|
||||
clear: false,
|
||||
cycle: false,
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (armed) {
|
||||
return {
|
||||
arm: false,
|
||||
clear: true,
|
||||
cycle: promptHit(cycles, { ...event, leader: true }),
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptHit(cycles, event)) {
|
||||
return {
|
||||
arm: false,
|
||||
clear: false,
|
||||
cycle: false,
|
||||
consume: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
arm: false,
|
||||
clear: false,
|
||||
cycle: true,
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||
const next: RunPrompt[] = []
|
||||
|
|
|
|||
|
|
@ -1,32 +1,21 @@
|
|||
// Boot-time resolution for direct interactive mode.
|
||||
//
|
||||
// These functions run concurrently at startup to gather everything the runtime
|
||||
// needs before the first frame: keybinds from TUI config, diff display style,
|
||||
// needs before the first frame: TUI keymap config, diff display style,
|
||||
// model variant list with context limits, and session history for the prompt
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { stringifyKeyStroke } from "@opentui/keymap"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types"
|
||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||
leader: TuiKeybind.LeaderDefault,
|
||||
leaderTimeout: 2000,
|
||||
commandList: [{ key: "ctrl+p" }],
|
||||
variantCycle: [{ key: "ctrl+t" }],
|
||||
interrupt: [{ key: "escape" }],
|
||||
historyPrevious: [{ key: "up" }],
|
||||
historyNext: [{ key: "down" }],
|
||||
inputClear: [{ key: "ctrl+c" }],
|
||||
inputSubmit: [{ key: "return" }],
|
||||
inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }],
|
||||
}
|
||||
const DEFAULT_LEADER_TIMEOUT = 2000
|
||||
|
||||
export type ModelInfo = {
|
||||
providers: RunProvider[]
|
||||
|
|
@ -52,7 +41,7 @@ type BootService = {
|
|||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) => Effect.Effect<SessionInfo>
|
||||
readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
|
||||
readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
|
||||
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
|
||||
}
|
||||
|
||||
|
|
@ -80,28 +69,27 @@ function emptySessionInfo(): SessionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function leaderKey(config: Config) {
|
||||
const key = config.keybinds.get("leader")?.[0]?.key
|
||||
if (!key) return TuiKeybind.LeaderDefault
|
||||
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
||||
function defaultRunTuiConfig(): RunTuiConfig {
|
||||
const keybinds = TuiKeybind.parse({})
|
||||
return {
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: DEFAULT_LEADER_TIMEOUT,
|
||||
diff_style: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
function footerKeybinds(config: Config | undefined): FooterKeybinds {
|
||||
function runTuiConfig(config: Config | undefined): RunTuiConfig {
|
||||
if (!config) {
|
||||
return DEFAULT_KEYBINDS
|
||||
return defaultRunTuiConfig()
|
||||
}
|
||||
|
||||
return {
|
||||
leader: leaderKey(config),
|
||||
leaderTimeout: config.leader_timeout,
|
||||
commandList: config.keybinds.get("command.palette.show"),
|
||||
variantCycle: config.keybinds.get("variant.cycle"),
|
||||
interrupt: config.keybinds.get("session.interrupt"),
|
||||
historyPrevious: config.keybinds.get("prompt.history.previous"),
|
||||
historyNext: config.keybinds.get("prompt.history.next"),
|
||||
inputClear: config.keybinds.get("prompt.clear"),
|
||||
inputSubmit: config.keybinds.get("input.submit"),
|
||||
inputNewline: config.keybinds.get("input.newline"),
|
||||
keybinds: config.keybinds,
|
||||
leader_timeout: config.leader_timeout,
|
||||
diff_style: config.diff_style ?? "auto",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,18 +163,18 @@ const layer = Layer.effect(
|
|||
}
|
||||
})
|
||||
|
||||
const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
|
||||
return footerKeybinds(yield* config())
|
||||
const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
|
||||
return runTuiConfig(yield* config())
|
||||
})
|
||||
|
||||
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
|
||||
return (yield* config())?.diff_style ?? "auto"
|
||||
return runTuiConfig(yield* config()).diff_style ?? "auto"
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resolveModelInfo,
|
||||
resolveSessionInfo,
|
||||
resolveFooterKeybinds,
|
||||
resolveRunTuiConfig,
|
||||
resolveDiffStyle,
|
||||
})
|
||||
}),
|
||||
|
|
@ -212,9 +200,9 @@ export async function resolveSessionInfo(
|
|||
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
||||
}
|
||||
|
||||
// Reads keybind overrides from TUI config and merges them with defaults.
|
||||
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
|
||||
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
|
||||
// Reads TUI config once for direct mode keymap setup and display preferences.
|
||||
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
|
||||
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
||||
}
|
||||
|
||||
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||
|
|
@ -17,15 +19,14 @@ import { entrySplash, exitSplash, splashMeta } from "./splash"
|
|||
import { resolveRunTheme } from "./theme"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterKeybinds,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunAgent,
|
||||
RunDiffStyle,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { formatModelLabel } from "./variant.shared"
|
||||
|
||||
|
|
@ -61,8 +62,7 @@ export type LifecycleInput = {
|
|||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
keybinds: FooterKeybinds
|
||||
diffStyle: RunDiffStyle
|
||||
tuiConfig: RunTuiConfig
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
|
|
@ -169,6 +169,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
},
|
||||
async () => {
|
||||
const source = resolveInteractiveStdin()
|
||||
let unregisterKeymap: (() => void) | undefined
|
||||
|
||||
try {
|
||||
const renderer = await createCliRenderer({
|
||||
|
|
@ -188,6 +189,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
})
|
||||
const theme = await resolveRunTheme(renderer)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
|
|
@ -230,8 +233,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
history: input.history,
|
||||
theme,
|
||||
wrote,
|
||||
keybinds: input.keybinds,
|
||||
diffStyle: input.diffStyle,
|
||||
keymap,
|
||||
tuiConfig: input.tuiConfig,
|
||||
diffStyle: input.tuiConfig.diff_style ?? "auto",
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onQuestionReply: input.onQuestionReply,
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
|
|
@ -293,6 +297,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
footer.close()
|
||||
await footer.idle().catch(() => {})
|
||||
footer.destroy()
|
||||
unregisterKeymap?.()
|
||||
shutdown(renderer)
|
||||
source.cleanup?.()
|
||||
}
|
||||
|
|
@ -305,6 +310,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
close,
|
||||
}
|
||||
} catch (error) {
|
||||
unregisterKeymap?.()
|
||||
source.cleanup?.()
|
||||
throw error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// runInteractiveLocalMode -- used for local in-process mode (no server)
|
||||
//
|
||||
// Both delegate to runInteractiveRuntime, which:
|
||||
// 1. resolves keybinds, diff style, model info, and session history,
|
||||
// 1. resolves TUI config, model info, and session history,
|
||||
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
||||
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
||||
// local sessions,
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
|
||||
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||
import { trace } from "./trace"
|
||||
|
|
@ -173,8 +173,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||
async (span) => {
|
||||
const start = performance.now()
|
||||
const log = trace()
|
||||
const keybindTask = resolveFooterKeybinds()
|
||||
const diffTask = resolveDiffStyle()
|
||||
const tuiConfigTask = resolveRunTuiConfig()
|
||||
const ctx = await input.boot()
|
||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
||||
const sessionTask =
|
||||
|
|
@ -186,9 +185,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||
variant: undefined,
|
||||
})
|
||||
const savedTask = resolveSavedVariant(ctx.model)
|
||||
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
|
||||
keybindTask,
|
||||
diffTask,
|
||||
const [tuiConfig, session, savedVariant] = await Promise.all([
|
||||
tuiConfigTask,
|
||||
sessionTask,
|
||||
savedTask,
|
||||
])
|
||||
|
|
@ -252,8 +250,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
keybinds,
|
||||
diffStyle,
|
||||
tuiConfig,
|
||||
onPermissionReply: async (next) => {
|
||||
if (state.demo?.permission(next)) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@
|
|||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
|
|
@ -265,20 +264,7 @@ export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
|
|||
|
||||
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
||||
|
||||
type FooterBinding = Binding<Renderable, KeyEvent>
|
||||
|
||||
export type FooterKeybinds = {
|
||||
leader: string
|
||||
leaderTimeout: number
|
||||
commandList: readonly FooterBinding[]
|
||||
variantCycle: readonly FooterBinding[]
|
||||
interrupt: readonly FooterBinding[]
|
||||
historyPrevious: readonly FooterBinding[]
|
||||
historyNext: readonly FooterBinding[]
|
||||
inputClear: readonly FooterBinding[]
|
||||
inputSubmit: readonly FooterBinding[]
|
||||
inputNewline: readonly FooterBinding[]
|
||||
}
|
||||
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type CliRenderer } from "@opentui/core"
|
||||
import { InputRenderable, TextareaRenderable, type CliRenderer } from "@opentui/core"
|
||||
import * as addons from "@opentui/keymap/addons/opentui"
|
||||
import { stringifyKeyStroke } from "@opentui/keymap"
|
||||
import {
|
||||
|
|
@ -31,6 +31,7 @@ type CommandSlashEntry = {
|
|||
onSelect: () => void
|
||||
}
|
||||
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
||||
type FormatConfig = Pick<TuiConfig.Resolved, "keybinds">
|
||||
|
||||
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
||||
|
||||
|
|
@ -160,13 +161,22 @@ const inputCommands = [
|
|||
"input.submit",
|
||||
] as const
|
||||
|
||||
function leaderDisplay(config: TuiConfig.Resolved) {
|
||||
function hasManagedTextareaFocus(renderer: CliRenderer) {
|
||||
const editor = renderer.currentFocusedEditor
|
||||
return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
|
||||
}
|
||||
|
||||
function leaderDisplay(config: FormatConfig) {
|
||||
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
||||
if (!key) return TuiKeybind.LeaderDefault
|
||||
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
||||
}
|
||||
|
||||
function formatOptions(config: TuiConfig.Resolved) {
|
||||
function leaderKey(config: FormatConfig) {
|
||||
return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
||||
}
|
||||
|
||||
function formatOptions(config: FormatConfig) {
|
||||
return {
|
||||
tokenDisplay: {
|
||||
[LEADER_TOKEN]: leaderDisplay(config),
|
||||
|
|
@ -182,13 +192,13 @@ function formatOptions(config: TuiConfig.Resolved) {
|
|||
} as const
|
||||
}
|
||||
|
||||
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: TuiConfig.Resolved) {
|
||||
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
|
||||
return formatKeySequenceExtra(parts, formatOptions(config))
|
||||
}
|
||||
|
||||
export function formatKeyBindings(
|
||||
bindings: Parameters<typeof formatCommandBindingsExtra>[0],
|
||||
config: TuiConfig.Resolved,
|
||||
config: FormatConfig,
|
||||
) {
|
||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
||||
}
|
||||
|
|
@ -202,15 +212,18 @@ export function registerOpencodeKeymap(
|
|||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||
const offAliasExpander = registerKeyAliases(keymap)
|
||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||
const offLeader = addons.registerTimedLeader(keymap, {
|
||||
trigger: config.keybinds.get(LEADER_TOKEN),
|
||||
name: LEADER_TOKEN,
|
||||
timeoutMs: config.leader_timeout,
|
||||
})
|
||||
const leader = leaderKey(config)
|
||||
const offLeader = leader
|
||||
? addons.registerTimedLeader(keymap, {
|
||||
trigger: leader,
|
||||
name: LEADER_TOKEN,
|
||||
timeoutMs: config.leader_timeout,
|
||||
})
|
||||
: () => {}
|
||||
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
||||
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
||||
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
|
||||
enabled: () => renderer.currentFocusedEditor !== null,
|
||||
enabled: () => hasManagedTextareaFocus(renderer),
|
||||
bindings: config.keybinds.gather("input", inputCommands),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16"
|
||||
"@opentui/core": ">=0.3.0",
|
||||
"@opentui/keymap": ">=0.3.0",
|
||||
"@opentui/solid": ">=0.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue