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

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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))
}

View File

@@ -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[] = []

View File

@@ -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> {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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),
})