mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a494997a31 | |||
| 449f2aeeb4 |
@@ -577,9 +577,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.2.16",
|
"@opentui/core": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/keymap": ">=0.2.16",
|
"@opentui/keymap": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/solid": ">=0.2.16",
|
"@opentui/solid": "0.0.0-20260528-8a59fd68",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -809,9 +809,9 @@
|
|||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "0.0.0-20260528-8a59fd68",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -826,9 +826,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.2.16",
|
"@opentui/core": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/keymap": "0.2.16",
|
"@opentui/keymap": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/solid": "0.2.16",
|
"@opentui/solid": "0.0.0-20260528-8a59fd68",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -1718,23 +1718,23 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
"@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.0.0-20260528-8a59fd68", "", { "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.0.0-20260528-8a59fd68", "@opentui/core-darwin-x64": "0.0.0-20260528-8a59fd68", "@opentui/core-linux-arm64": "0.0.0-20260528-8a59fd68", "@opentui/core-linux-x64": "0.0.0-20260528-8a59fd68", "@opentui/core-win32-arm64": "0.0.0-20260528-8a59fd68", "@opentui/core-win32-x64": "0.0.0-20260528-8a59fd68" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e8w0wVM6TySnvm/NE06/HeA1foKXxFwWXqs1eWwHvlYsVVX6Cx6VsU5lDe24ym0vszIUsi1AMNxSgVfZfQacKA=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dao+sfwcpP4GsGfMEp8eHon3Ttg2DEpyTUZs+H2Je/b94yhhtT3EVb77mh96QFsvTlOO2wN2IVc7IBY3yDVvWA=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "os": "darwin", "cpu": "x64" }, "sha512-kcY5iADOAcQweRqyH2z2sP3NHvsz8ncMtVB+8nccvuClTe3v2yoZEjkc+wPS8Hskn0ktGDLfbQDp+lO+BMDGnA=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "os": "linux", "cpu": "arm64" }, "sha512-IAM5XcmZW4/YLUkH0F8sfzIhtjYimmEVeGHnKpk1uqpMCG2EJ7J6TjhQeOmJecQrNVlYGnbAhvOpotskKvO9bw=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "os": "linux", "cpu": "x64" }, "sha512-VtL2iPrZ54QoU/2anVW3Spmd5iunH/9VvLf5/b5pxzhOTNpKv3wJqoVIWhLrhudOc0Nbnq88vVCR/FTsAb9ONQ=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "os": "win32", "cpu": "arm64" }, "sha512-18kkSjfIm/aVo/Xdb+CwI4DVl0updSenC/6Kn0Z2rEmtRotPV3BmBGSTd/oVl6LMeTwm6CqKzaeAmxE6yJrfHw=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "os": "win32", "cpu": "x64" }, "sha512-/saL5UA41Exp6ln2N1xg1lHslNcPkEhfu2vP1IS44kUiroRzqRE5fj1Fc65YdYXlW0mOgdqyd/YLQLGDjan/Kg=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "dependencies": { "@opentui/core": "0.0.0-20260528-8a59fd68" }, "peerDependencies": { "@opentui/react": "0.0.0-20260528-8a59fd68", "@opentui/solid": "0.0.0-20260528-8a59fd68", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YCgmQLfftSoJnt/m5Wq1BD2TEsZZkmGk90DZbdcp5wxLAasgp2uMOz0+K8BYc6qlb4uR5GfAr0UDsy+/2C+1iA=="],
|
||||||
|
|
||||||
"@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.0.0-20260528-8a59fd68", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260528-8a59fd68", "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-Aay3vdqJ1EAN3aFVociR4nFLR++R8pdXX5heSy9ZzY3Qfw1bNAeomG+EjIG9abkqPrmKhsJHdf0r/G6tDquWJA=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -38,9 +38,9 @@
|
|||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.2.16",
|
"@opentui/core": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/keymap": "0.2.16",
|
"@opentui/keymap": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/solid": "0.2.16",
|
"@opentui/solid": "0.0.0-20260528-8a59fd68",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -132,9 +132,9 @@
|
|||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "0.0.0-20260528-8a59fd68",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useKeyboard, type JSX } from "@opentui/solid"
|
|||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import { formatBindings } from "./keymap.shared"
|
|
||||||
import type { RunFooterTheme } from "./theme"
|
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 & {
|
type PanelEntry = RunFooterMenuItem & {
|
||||||
category: string
|
category: string
|
||||||
@@ -296,7 +295,7 @@ export function RunCommandMenuBody(props: {
|
|||||||
commands: Accessor<RunCommand[] | undefined>
|
commands: Accessor<RunCommand[] | undefined>
|
||||||
subagents: Accessor<FooterSubagentTab[]>
|
subagents: Accessor<FooterSubagentTab[]>
|
||||||
variants: Accessor<string[]>
|
variants: Accessor<string[]>
|
||||||
keybinds: FooterKeybinds
|
variantCycle: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onModel: () => void
|
onModel: () => void
|
||||||
onSubagent: () => void
|
onSubagent: () => void
|
||||||
@@ -334,7 +333,7 @@ export function RunCommandMenuBody(props: {
|
|||||||
action: "variant.cycle",
|
action: "variant.cycle",
|
||||||
category: "Suggested",
|
category: "Suggested",
|
||||||
display: "Variant cycle",
|
display: "Variant cycle",
|
||||||
footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader),
|
footer: props.variantCycle,
|
||||||
keywords: "variant cycle",
|
keywords: "variant cycle",
|
||||||
},
|
},
|
||||||
...(props.variants().length > 0
|
...(props.variants().length > 0
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Prompt textarea component and its state machine for direct interactive mode.
|
// Prompt textarea component and its state machine for direct interactive mode.
|
||||||
//
|
//
|
||||||
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
// createPromptState() wires keymap command layers, history navigation, and
|
||||||
// and `@` autocomplete for files, subagents, and MCP resources.
|
// `@` autocomplete for files, subagents, and MCP resources.
|
||||||
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
||||||
// while the footer view renders the current menu state below it.
|
// while the footer view renders the current menu state below it.
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
import { useRenderer } from "@opentui/solid"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
@@ -20,15 +20,12 @@ import {
|
|||||||
mentionTriggerIndex,
|
mentionTriggerIndex,
|
||||||
isNewCommand,
|
isNewCommand,
|
||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
promptCycle,
|
|
||||||
promptHit,
|
|
||||||
promptInfo,
|
|
||||||
promptKeys,
|
|
||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
} from "./prompt.shared"
|
} from "./prompt.shared"
|
||||||
|
import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap"
|
||||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import type { RunFooterTheme } from "./theme"
|
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_ROWS = FOOTER_MENU_ROWS
|
||||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||||
@@ -69,7 +66,7 @@ type PromptInput = {
|
|||||||
subagents: Accessor<number>
|
subagents: Accessor<number>
|
||||||
resources: Accessor<RunResource[]>
|
resources: Accessor<RunResource[]>
|
||||||
commands: Accessor<RunCommand[] | undefined>
|
commands: Accessor<RunCommand[] | undefined>
|
||||||
keybinds: FooterKeybinds
|
tuiConfig: RunTuiConfig
|
||||||
state: Accessor<FooterState>
|
state: Accessor<FooterState>
|
||||||
view: Accessor<string>
|
view: Accessor<string>
|
||||||
prompt: Accessor<boolean>
|
prompt: Accessor<boolean>
|
||||||
@@ -89,7 +86,6 @@ type PromptInput = {
|
|||||||
|
|
||||||
export type PromptState = {
|
export type PromptState = {
|
||||||
placeholder: Accessor<StyledText | string>
|
placeholder: Accessor<StyledText | string>
|
||||||
bindings: Accessor<KeyBinding[]>
|
|
||||||
shell: Accessor<boolean>
|
shell: Accessor<boolean>
|
||||||
visible: Accessor<boolean>
|
visible: Accessor<boolean>
|
||||||
options: Accessor<PromptOption[]>
|
options: Accessor<PromptOption[]>
|
||||||
@@ -199,7 +195,6 @@ export function hintFlags(width: number) {
|
|||||||
export function RunPromptBody(props: {
|
export function RunPromptBody(props: {
|
||||||
theme: () => RunFooterTheme
|
theme: () => RunFooterTheme
|
||||||
placeholder: () => StyledText | string
|
placeholder: () => StyledText | string
|
||||||
bindings: () => KeyBinding[]
|
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onKeyDown: (event: KeyEvent) => void
|
onKeyDown: (event: KeyEvent) => void
|
||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
@@ -263,7 +258,6 @@ export function RunPromptBody(props: {
|
|||||||
backgroundColor={props.theme().surface}
|
backgroundColor={props.theme().surface}
|
||||||
focusedBackgroundColor={props.theme().surface}
|
focusedBackgroundColor={props.theme().surface}
|
||||||
cursorColor={props.theme().text}
|
cursorColor={props.theme().text}
|
||||||
keyBindings={props.bindings()}
|
|
||||||
onSubmit={props.onSubmit}
|
onSubmit={props.onSubmit}
|
||||||
onKeyDown={props.onKeyDown}
|
onKeyDown={props.onKeyDown}
|
||||||
onPaste={() => {
|
onPaste={() => {
|
||||||
@@ -280,8 +274,6 @@ export function RunPromptBody(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptState(input: PromptInput): PromptState {
|
export function createPromptState(input: PromptInput): PromptState {
|
||||||
const keys = createMemo(() => promptKeys(input.keybinds))
|
|
||||||
const bindings = createMemo(() => keys().bindings)
|
|
||||||
const [shell, setShell] = createSignal(false)
|
const [shell, setShell] = createSignal(false)
|
||||||
const placeholder = createMemo(() => {
|
const placeholder = createMemo(() => {
|
||||||
if (shell()) {
|
if (shell()) {
|
||||||
@@ -301,8 +293,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
let draft: RunPrompt = { text: "", parts: [] }
|
let draft: RunPrompt = { text: "", parts: [] }
|
||||||
let stash: RunPrompt = { text: "", parts: [] }
|
let stash: RunPrompt = { text: "", parts: [] }
|
||||||
let area: TextareaRenderable | undefined
|
let area: TextareaRenderable | undefined
|
||||||
let leader = false
|
|
||||||
let timeout: NodeJS.Timeout | undefined
|
|
||||||
let tick = false
|
let tick = false
|
||||||
let prev = input.view()
|
let prev = input.view()
|
||||||
let type = 0
|
let type = 0
|
||||||
@@ -461,24 +451,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
|
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 = () => {
|
const hide = () => {
|
||||||
setMode(false)
|
setMode(false)
|
||||||
setQuery("")
|
setQuery("")
|
||||||
@@ -742,7 +714,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
|
|
||||||
const move = (dir: -1 | 1, event: KeyEvent) => {
|
const move = (dir: -1 | 1, event: KeyEvent) => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (history.index === null && dir === -1) {
|
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)
|
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||||
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
history = next.state
|
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: [] })
|
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
|
||||||
restore(value, next.cursor)
|
restore(value, next.cursor)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycle = (event: KeyEvent): boolean => {
|
const historyCommand = (dir: -1 | 1, event: KeyEvent) => {
|
||||||
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
if (move(dir, event)) return
|
||||||
if (!next.consume) {
|
if (!area || area.isDestroyed) return false
|
||||||
return false
|
|
||||||
|
const endOffset = Bun.stringWidth(area.plainText)
|
||||||
|
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||||
|
area.cursorOffset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.clear) {
|
const end =
|
||||||
clear()
|
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) {
|
return false
|
||||||
arm()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.cycle) {
|
|
||||||
input.onCycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestExit = () => {
|
const requestExit = () => {
|
||||||
@@ -912,178 +883,190 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (event: KeyEvent) => {
|
const baseBindingsEnabled = () => {
|
||||||
const key = promptInfo(event)
|
const current = input.view()
|
||||||
if (visible()) {
|
if (current === "command") return false
|
||||||
const name = event.name.toLowerCase()
|
if (current === "model") return false
|
||||||
const ctrl = event.ctrl && !event.meta && !event.shift
|
if (current === "variant") return false
|
||||||
if (name === "up" || (ctrl && name === "p")) {
|
if (current === "subagent-menu") return false
|
||||||
event.preventDefault()
|
return true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeyboard((event) => {
|
useBindings(() => ({
|
||||||
if (input.prompt()) {
|
mode: OPENCODE_BASE_MODE,
|
||||||
return
|
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 (
|
useBindings(() => ({
|
||||||
input.view() === "command" ||
|
mode: OPENCODE_BASE_MODE,
|
||||||
input.view() === "model" ||
|
enabled: input.prompt(),
|
||||||
input.view() === "variant" ||
|
commands: [
|
||||||
input.view() === "subagent-menu"
|
{
|
||||||
) {
|
name: "session.interrupt",
|
||||||
return
|
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))) {
|
useBindings(() => ({
|
||||||
const handled = requestExit()
|
mode: OPENCODE_BASE_MODE,
|
||||||
if (handled) {
|
enabled: input.prompt() && !visible(),
|
||||||
event.preventDefault()
|
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) => {
|
const submitPrompt = (next: RunPrompt) => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
@@ -1144,7 +1127,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
clear()
|
|
||||||
if (area && !area.isDestroyed) {
|
if (area && !area.isDestroyed) {
|
||||||
area.off("line-info-change", scheduleRows)
|
area.off("line-info-change", scheduleRows)
|
||||||
}
|
}
|
||||||
@@ -1188,7 +1170,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
syncDraft()
|
syncDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
clear()
|
|
||||||
hide()
|
hide()
|
||||||
prev = kind
|
prev = kind
|
||||||
if (kind !== "prompt") {
|
if (kind !== "prompt") {
|
||||||
@@ -1202,7 +1183,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
placeholder,
|
placeholder,
|
||||||
bindings,
|
|
||||||
shell,
|
shell,
|
||||||
visible,
|
visible,
|
||||||
options,
|
options,
|
||||||
|
|||||||
@@ -24,22 +24,22 @@
|
|||||||
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
// 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
|
// two-press pattern where the first press shows a hint and the second press
|
||||||
// within 5 seconds actually fires the action.
|
// 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 { render } from "@opentui/solid"
|
||||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap"
|
||||||
import { withRunSpan } from "./otel"
|
import { withRunSpan } from "./otel"
|
||||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||||
import { printableBinding } from "./prompt.shared"
|
|
||||||
import { RunFooterView } from "./footer.view"
|
import { RunFooterView } from "./footer.view"
|
||||||
import { RunScrollbackStream } from "./scrollback.surface"
|
import { RunScrollbackStream } from "./scrollback.surface"
|
||||||
import type { RunTheme } from "./theme"
|
import type { RunTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
FooterApi,
|
FooterApi,
|
||||||
FooterEvent,
|
FooterEvent,
|
||||||
FooterKeybinds,
|
|
||||||
FooterPatch,
|
FooterPatch,
|
||||||
FooterPromptRoute,
|
FooterPromptRoute,
|
||||||
FooterState,
|
FooterState,
|
||||||
@@ -55,6 +55,7 @@ import type {
|
|||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
RunResource,
|
RunResource,
|
||||||
|
RunTuiConfig,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
@@ -80,7 +81,8 @@ type RunFooterOptions = {
|
|||||||
first: boolean
|
first: boolean
|
||||||
history?: RunPrompt[]
|
history?: RunPrompt[]
|
||||||
theme: RunTheme
|
theme: RunTheme
|
||||||
keybinds: FooterKeybinds
|
keymap: Keymap<Renderable, KeyEvent>
|
||||||
|
tuiConfig: RunTuiConfig
|
||||||
diffStyle: RunDiffStyle
|
diffStyle: RunDiffStyle
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||||
@@ -195,7 +197,6 @@ export class RunFooter implements FooterApi {
|
|||||||
private autocomplete = false
|
private autocomplete = false
|
||||||
private interruptTimeout: NodeJS.Timeout | undefined
|
private interruptTimeout: NodeJS.Timeout | undefined
|
||||||
private exitTimeout: NodeJS.Timeout | undefined
|
private exitTimeout: NodeJS.Timeout | undefined
|
||||||
private interruptHint: string
|
|
||||||
private requestExitHandler: (() => boolean) | undefined
|
private requestExitHandler: (() => boolean) | undefined
|
||||||
private scrollback: RunScrollbackStream
|
private scrollback: RunScrollbackStream
|
||||||
|
|
||||||
@@ -249,7 +250,6 @@ export class RunFooter implements FooterApi {
|
|||||||
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
||||||
}
|
}
|
||||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
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, {
|
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
||||||
diffStyle: options.diffStyle,
|
diffStyle: options.diffStyle,
|
||||||
wrote: options.wrote,
|
wrote: options.wrote,
|
||||||
@@ -259,42 +259,48 @@ export class RunFooter implements FooterApi {
|
|||||||
|
|
||||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||||
|
|
||||||
|
const footer = this
|
||||||
void render(
|
void render(
|
||||||
() =>
|
() =>
|
||||||
createComponent(RunFooterView, {
|
createComponent(OpencodeKeymapProvider, {
|
||||||
directory: options.directory,
|
keymap: options.keymap,
|
||||||
state: this.state,
|
get children() {
|
||||||
view: this.view,
|
return createComponent(RunFooterView, {
|
||||||
subagent: this.subagent,
|
directory: options.directory,
|
||||||
findFiles: options.findFiles,
|
state: footer.state,
|
||||||
agents: this.agents,
|
view: footer.view,
|
||||||
resources: this.resources,
|
subagent: footer.subagent,
|
||||||
commands: this.commands,
|
findFiles: options.findFiles,
|
||||||
providers: this.providers,
|
agents: this.agents,
|
||||||
currentModel: this.currentModel,
|
resources: this.resources,
|
||||||
variants: this.variants,
|
commands: this.commands,
|
||||||
currentVariant: this.currentVariant,
|
providers: this.providers,
|
||||||
theme: options.theme,
|
currentModel: this.currentModel,
|
||||||
diffStyle: options.diffStyle,
|
variants: this.variants,
|
||||||
keybinds: options.keybinds,
|
currentVariant: this.currentVariant,
|
||||||
history: options.history,
|
theme: options.theme,
|
||||||
agent: options.agentLabel,
|
diffStyle: options.diffStyle,
|
||||||
onSubmit: this.handlePrompt,
|
tuiConfig: options.tuiConfig,
|
||||||
onPermissionReply: this.handlePermissionReply,
|
history: options.history,
|
||||||
onQuestionReply: this.handleQuestionReply,
|
agent: options.agentLabel,
|
||||||
onQuestionReject: this.handleQuestionReject,
|
onSubmit: footer.handlePrompt,
|
||||||
onCycle: this.handleCycle,
|
onPermissionReply: footer.handlePermissionReply,
|
||||||
onInterrupt: this.handleInterrupt,
|
onQuestionReply: footer.handleQuestionReply,
|
||||||
onInputClear: this.handleInputClear,
|
onQuestionReject: footer.handleQuestionReject,
|
||||||
onExitRequest: this.handleExit,
|
onCycle: footer.handleCycle,
|
||||||
onRequestExit: this.setRequestExitHandler,
|
onInterrupt: footer.handleInterrupt,
|
||||||
onExit: () => this.close(),
|
onInputClear: footer.handleInputClear,
|
||||||
onModelSelect: this.handleModelSelect,
|
onExitRequest: footer.handleExit,
|
||||||
onVariantSelect: this.handleVariantSelect,
|
onRequestExit: footer.setRequestExitHandler,
|
||||||
onRows: this.syncRows,
|
onExit: () => footer.close(),
|
||||||
onLayout: this.syncLayout,
|
onModelSelect: footer.handleModelSelect,
|
||||||
onStatus: this.setStatus,
|
onVariantSelect: footer.handleVariantSelect,
|
||||||
onSubagentSelect: options.onSubagentSelect,
|
onRows: footer.syncRows,
|
||||||
|
onLayout: footer.syncLayout,
|
||||||
|
onStatus: footer.setStatus,
|
||||||
|
onSubagentSelect: options.onSubagentSelect,
|
||||||
|
})
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.renderer,
|
this.renderer,
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
@@ -781,6 +787,13 @@ export class RunFooter implements FooterApi {
|
|||||||
}, 5000)
|
}, 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 {
|
private clearExitTimer(): void {
|
||||||
if (!this.exitTimeout) {
|
if (!this.exitTimeout) {
|
||||||
return
|
return
|
||||||
@@ -815,7 +828,7 @@ export class RunFooter implements FooterApi {
|
|||||||
|
|
||||||
if (next < 2) {
|
if (next < 2) {
|
||||||
this.armInterruptTimer()
|
this.armInterruptTimer()
|
||||||
this.patch({ status: `${this.interruptHint} again to interrupt` })
|
this.patch({ status: `${this.interruptHint()} again to interrupt` })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// All state comes from the parent RunFooter through SolidJS signals.
|
// All state comes from the parent RunFooter through SolidJS signals.
|
||||||
// The view itself is stateless except for derived memos.
|
// The view itself is stateless except for derived memos.
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @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 { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||||
@@ -26,9 +26,14 @@ import { RunFooterSubagentBody } from "./footer.subagent"
|
|||||||
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||||
import { RunPermissionBody } from "./footer.permission"
|
import { RunPermissionBody } from "./footer.permission"
|
||||||
import { RunQuestionBody } from "./footer.question"
|
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 {
|
import type {
|
||||||
FooterKeybinds,
|
|
||||||
FooterPromptRoute,
|
FooterPromptRoute,
|
||||||
FooterState,
|
FooterState,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
@@ -43,6 +48,7 @@ import type {
|
|||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
RunResource,
|
RunResource,
|
||||||
|
RunTuiConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||||
|
|
||||||
@@ -75,7 +81,7 @@ type RunFooterViewProps = {
|
|||||||
subagent?: () => FooterSubagentState
|
subagent?: () => FooterSubagentState
|
||||||
theme?: RunTheme
|
theme?: RunTheme
|
||||||
diffStyle?: RunDiffStyle
|
diffStyle?: RunDiffStyle
|
||||||
keybinds: FooterKeybinds
|
tuiConfig: RunTuiConfig
|
||||||
history?: RunPrompt[]
|
history?: RunPrompt[]
|
||||||
agent: string
|
agent: string
|
||||||
onSubmit: (input: RunPrompt) => boolean
|
onSubmit: (input: RunPrompt) => boolean
|
||||||
@@ -149,9 +155,24 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const current = route()
|
const current = route()
|
||||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||||
})
|
})
|
||||||
const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader))
|
const command = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
formatKeyBindings(
|
||||||
const commandKeys = createMemo(() => promptBindings(props.keybinds.commandList, props.keybinds.leader))
|
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 hints = createMemo(() => hintFlags(term().width))
|
||||||
const busy = createMemo(() => props.state().phase === "running")
|
const busy = createMemo(() => props.state().phase === "running")
|
||||||
const armed = createMemo(() => props.state().interrupt > 0)
|
const armed = createMemo(() => props.state().interrupt > 0)
|
||||||
@@ -257,7 +278,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
subagents: () => tabs().length,
|
subagents: () => tabs().length,
|
||||||
resources: props.resources,
|
resources: props.resources,
|
||||||
commands: props.commands,
|
commands: props.commands,
|
||||||
keybinds: props.keybinds,
|
tuiConfig: props.tuiConfig,
|
||||||
state: props.state,
|
state: props.state,
|
||||||
view: promptView,
|
view: promptView,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -285,30 +306,28 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
props.onRequestExit?.(undefined)
|
props.onRequestExit?.(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
useKeyboard((event) => {
|
useBindings(() => ({
|
||||||
if (event.defaultPrevented) {
|
mode: OPENCODE_BASE_MODE,
|
||||||
return
|
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
||||||
}
|
commands: [
|
||||||
|
{
|
||||||
if (active().type !== "prompt") {
|
name: "command.palette.show",
|
||||||
return
|
title: "Open command palette",
|
||||||
}
|
category: "Prompt",
|
||||||
|
run: openCommand,
|
||||||
if (route().type !== "composer") {
|
},
|
||||||
return
|
{
|
||||||
}
|
name: "variant.cycle",
|
||||||
|
title: "Cycle model variant",
|
||||||
if (composer.visible()) {
|
category: "Model",
|
||||||
return
|
run: props.onCycle,
|
||||||
}
|
},
|
||||||
|
],
|
||||||
if (!promptHit(commandKeys(), promptInfo(event))) {
|
bindings: [
|
||||||
return
|
...props.tuiConfig.keybinds.get("command.palette.show"),
|
||||||
}
|
...props.tuiConfig.keybinds.get("variant.cycle"),
|
||||||
|
],
|
||||||
event.preventDefault()
|
}))
|
||||||
openCommand()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = route()
|
const current = route()
|
||||||
@@ -407,7 +426,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
<RunPromptBody
|
<RunPromptBody
|
||||||
theme={theme}
|
theme={theme}
|
||||||
placeholder={composer.placeholder}
|
placeholder={composer.placeholder}
|
||||||
bindings={composer.bindings}
|
|
||||||
onSubmit={composer.onSubmit}
|
onSubmit={composer.onSubmit}
|
||||||
onKeyDown={composer.onKeyDown}
|
onKeyDown={composer.onKeyDown}
|
||||||
onContentChange={composer.onContentChange}
|
onContentChange={composer.onContentChange}
|
||||||
@@ -430,7 +448,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
commands={props.commands}
|
commands={props.commands}
|
||||||
subagents={tabs}
|
subagents={tabs}
|
||||||
variants={props.variants}
|
variants={props.variants}
|
||||||
keybinds={props.keybinds}
|
variantCycle={variantCycle()}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
onModel={openModel}
|
onModel={openModel}
|
||||||
onSubagent={openSubagentMenu}
|
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.
|
// Pure state machine for the prompt input.
|
||||||
//
|
//
|
||||||
// Handles keybind parsing, history ring navigation, and the leader-key
|
// Handles history ring navigation and prompt text helpers. All functions are
|
||||||
// sequence for variant cycling. All functions are pure -- they take state
|
// pure -- they take state in and return new state out, with no side effects.
|
||||||
// in and return new state out, with no side effects.
|
|
||||||
//
|
//
|
||||||
// The history ring (PromptHistoryState) stores past prompts and tracks
|
// The history ring (PromptHistoryState) stores past prompts and tracks
|
||||||
// the current browse position. When the user arrows up at cursor offset 0,
|
// 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
|
// the current draft is saved and history begins. Arrowing past the end
|
||||||
// restores the draft.
|
// 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"
|
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
|
||||||
import { formatBinding, parseBindings } from "./keymap.shared"
|
import type { RunPrompt } from "./types"
|
||||||
import type { FooterKeybinds, RunPrompt } from "./types"
|
|
||||||
|
|
||||||
const HISTORY_LIMIT = 200
|
const HISTORY_LIMIT = 200
|
||||||
|
|
||||||
@@ -24,36 +18,6 @@ export type PromptHistoryState = {
|
|||||||
draft: string
|
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 = {
|
export type PromptMove = {
|
||||||
state: PromptHistoryState
|
state: PromptHistoryState
|
||||||
text?: string
|
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)
|
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 {
|
export function isExitCommand(input: string): boolean {
|
||||||
const text = input.trim().toLowerCase()
|
const text = input.trim().toLowerCase()
|
||||||
return text === "/exit" || text === "/quit" || text === ":q"
|
return text === "/exit" || text === "/quit" || text === ":q"
|
||||||
@@ -174,59 +46,6 @@ export function isNewCommand(input: string): boolean {
|
|||||||
return input.trim().toLowerCase() === "/new"
|
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 {
|
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||||
const next: RunPrompt[] = []
|
const next: RunPrompt[] = []
|
||||||
|
|||||||
@@ -1,32 +1,21 @@
|
|||||||
// Boot-time resolution for direct interactive mode.
|
// Boot-time resolution for direct interactive mode.
|
||||||
//
|
//
|
||||||
// These functions run concurrently at startup to gather everything the runtime
|
// 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
|
// 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
|
// history ring. All are async because they read config or hit the SDK, but
|
||||||
// none block each other.
|
// none block each other.
|
||||||
import { Context, Effect, Layer } from "effect"
|
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 { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { reusePendingTask } from "./runtime.shared"
|
import { reusePendingTask } from "./runtime.shared"
|
||||||
import { resolveSession, sessionHistory } from "./session.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"
|
import { pickVariant } from "./variant.shared"
|
||||||
|
|
||||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
const DEFAULT_LEADER_TIMEOUT = 2000
|
||||||
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" }],
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelInfo = {
|
export type ModelInfo = {
|
||||||
providers: RunProvider[]
|
providers: RunProvider[]
|
||||||
@@ -52,7 +41,7 @@ type BootService = {
|
|||||||
sessionID: string,
|
sessionID: string,
|
||||||
model: RunInput["model"],
|
model: RunInput["model"],
|
||||||
) => Effect.Effect<SessionInfo>
|
) => Effect.Effect<SessionInfo>
|
||||||
readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
|
readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
|
||||||
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
|
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,28 +69,27 @@ function emptySessionInfo(): SessionInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function leaderKey(config: Config) {
|
function defaultRunTuiConfig(): RunTuiConfig {
|
||||||
const key = config.keybinds.get("leader")?.[0]?.key
|
const keybinds = TuiKeybind.parse({})
|
||||||
if (!key) return TuiKeybind.LeaderDefault
|
return {
|
||||||
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
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) {
|
if (!config) {
|
||||||
return DEFAULT_KEYBINDS
|
return defaultRunTuiConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leader: leaderKey(config),
|
keybinds: config.keybinds,
|
||||||
leaderTimeout: config.leader_timeout,
|
leader_timeout: config.leader_timeout,
|
||||||
commandList: config.keybinds.get("command.palette.show"),
|
diff_style: config.diff_style ?? "auto",
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +163,18 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
|
const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
|
||||||
return footerKeybinds(yield* config())
|
return runTuiConfig(yield* config())
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
|
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
|
||||||
return (yield* config())?.diff_style ?? "auto"
|
return runTuiConfig(yield* config()).diff_style ?? "auto"
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolveModelInfo,
|
resolveModelInfo,
|
||||||
resolveSessionInfo,
|
resolveSessionInfo,
|
||||||
resolveFooterKeybinds,
|
resolveRunTuiConfig,
|
||||||
resolveDiffStyle,
|
resolveDiffStyle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -212,9 +200,9 @@ export async function resolveSessionInfo(
|
|||||||
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads keybind overrides from TUI config and merges them with defaults.
|
// Reads TUI config once for direct mode keymap setup and display preferences.
|
||||||
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
|
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
|
||||||
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
|
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
// 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().
|
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Session as SessionApi } from "@/session/session"
|
import { Session as SessionApi } from "@/session/session"
|
||||||
|
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||||
import * as Locale from "@/util/locale"
|
import * as Locale from "@/util/locale"
|
||||||
import { withRunSpan } from "./otel"
|
import { withRunSpan } from "./otel"
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
@@ -17,15 +19,14 @@ import { entrySplash, exitSplash, splashMeta } from "./splash"
|
|||||||
import { resolveRunTheme } from "./theme"
|
import { resolveRunTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
FooterApi,
|
FooterApi,
|
||||||
FooterKeybinds,
|
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
QuestionReject,
|
QuestionReject,
|
||||||
QuestionReply,
|
QuestionReply,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunDiffStyle,
|
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunResource,
|
RunResource,
|
||||||
|
RunTuiConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { formatModelLabel } from "./variant.shared"
|
import { formatModelLabel } from "./variant.shared"
|
||||||
|
|
||||||
@@ -61,8 +62,7 @@ export type LifecycleInput = {
|
|||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
keybinds: FooterKeybinds
|
tuiConfig: RunTuiConfig
|
||||||
diffStyle: RunDiffStyle
|
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||||
@@ -169,6 +169,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const source = resolveInteractiveStdin()
|
const source = resolveInteractiveStdin()
|
||||||
|
let unregisterKeymap: (() => void) | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const renderer = await createCliRenderer({
|
const renderer = await createCliRenderer({
|
||||||
@@ -188,6 +189,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
})
|
})
|
||||||
const theme = await resolveRunTheme(renderer)
|
const theme = await resolveRunTheme(renderer)
|
||||||
renderer.setBackgroundColor(theme.background)
|
renderer.setBackgroundColor(theme.background)
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
|
||||||
const state: SplashState = {
|
const state: SplashState = {
|
||||||
entry: false,
|
entry: false,
|
||||||
exit: false,
|
exit: false,
|
||||||
@@ -230,8 +233,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
history: input.history,
|
history: input.history,
|
||||||
theme,
|
theme,
|
||||||
wrote,
|
wrote,
|
||||||
keybinds: input.keybinds,
|
keymap,
|
||||||
diffStyle: input.diffStyle,
|
tuiConfig: input.tuiConfig,
|
||||||
|
diffStyle: input.tuiConfig.diff_style ?? "auto",
|
||||||
onPermissionReply: input.onPermissionReply,
|
onPermissionReply: input.onPermissionReply,
|
||||||
onQuestionReply: input.onQuestionReply,
|
onQuestionReply: input.onQuestionReply,
|
||||||
onQuestionReject: input.onQuestionReject,
|
onQuestionReject: input.onQuestionReject,
|
||||||
@@ -293,6 +297,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
footer.close()
|
footer.close()
|
||||||
await footer.idle().catch(() => {})
|
await footer.idle().catch(() => {})
|
||||||
footer.destroy()
|
footer.destroy()
|
||||||
|
unregisterKeymap?.()
|
||||||
shutdown(renderer)
|
shutdown(renderer)
|
||||||
source.cleanup?.()
|
source.cleanup?.()
|
||||||
}
|
}
|
||||||
@@ -305,6 +310,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
close,
|
close,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
unregisterKeymap?.()
|
||||||
source.cleanup?.()
|
source.cleanup?.()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// runInteractiveLocalMode -- used for local in-process mode (no server)
|
// runInteractiveLocalMode -- used for local in-process mode (no server)
|
||||||
//
|
//
|
||||||
// Both delegate to runInteractiveRuntime, which:
|
// 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),
|
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
||||||
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
||||||
// local sessions,
|
// local sessions,
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { createRunDemo } from "./demo"
|
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 { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||||
import { trace } from "./trace"
|
import { trace } from "./trace"
|
||||||
@@ -173,8 +173,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
const log = trace()
|
const log = trace()
|
||||||
const keybindTask = resolveFooterKeybinds()
|
const tuiConfigTask = resolveRunTuiConfig()
|
||||||
const diffTask = resolveDiffStyle()
|
|
||||||
const ctx = await input.boot()
|
const ctx = await input.boot()
|
||||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
||||||
const sessionTask =
|
const sessionTask =
|
||||||
@@ -186,9 +185,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
variant: undefined,
|
variant: undefined,
|
||||||
})
|
})
|
||||||
const savedTask = resolveSavedVariant(ctx.model)
|
const savedTask = resolveSavedVariant(ctx.model)
|
||||||
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
|
const [tuiConfig, session, savedVariant] = await Promise.all([
|
||||||
keybindTask,
|
tuiConfigTask,
|
||||||
diffTask,
|
|
||||||
sessionTask,
|
sessionTask,
|
||||||
savedTask,
|
savedTask,
|
||||||
])
|
])
|
||||||
@@ -252,8 +250,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
agent: state.agent,
|
agent: state.agent,
|
||||||
model: state.model,
|
model: state.model,
|
||||||
variant: state.activeVariant,
|
variant: state.activeVariant,
|
||||||
keybinds,
|
tuiConfig,
|
||||||
diffStyle,
|
|
||||||
onPermissionReply: async (next) => {
|
onPermissionReply: async (next) => {
|
||||||
if (state.demo?.permission(next)) {
|
if (state.demo?.permission(next)) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,9 +11,8 @@
|
|||||||
// → stream.ts bridges to footer API
|
// → stream.ts bridges to footer API
|
||||||
// → footer.ts queues commits and patches the footer view
|
// → footer.ts queues commits and patches the footer view
|
||||||
// → OpenTUI split-footer renderer writes to terminal
|
// → 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 { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
export type RunFilePart = {
|
export type RunFilePart = {
|
||||||
type: "file"
|
type: "file"
|
||||||
@@ -265,20 +264,7 @@ export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
|
|||||||
|
|
||||||
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
||||||
|
|
||||||
type FooterBinding = Binding<Renderable, KeyEvent>
|
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
|
||||||
|
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||||
// appends content (coalesced in the footer queue), "final" closes it.
|
// appends content (coalesced in the footer queue), "final" closes it.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type CommandSlashEntry = {
|
|||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}
|
}
|
||||||
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
||||||
|
type FormatConfig = Pick<TuiConfig.Resolved, "keybinds">
|
||||||
|
|
||||||
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
||||||
|
|
||||||
@@ -160,13 +161,13 @@ const inputCommands = [
|
|||||||
"input.submit",
|
"input.submit",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function leaderDisplay(config: TuiConfig.Resolved) {
|
function leaderDisplay(config: FormatConfig) {
|
||||||
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
||||||
if (!key) return TuiKeybind.LeaderDefault
|
if (!key) return TuiKeybind.LeaderDefault
|
||||||
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOptions(config: TuiConfig.Resolved) {
|
function formatOptions(config: FormatConfig) {
|
||||||
return {
|
return {
|
||||||
tokenDisplay: {
|
tokenDisplay: {
|
||||||
[LEADER_TOKEN]: leaderDisplay(config),
|
[LEADER_TOKEN]: leaderDisplay(config),
|
||||||
@@ -182,13 +183,13 @@ function formatOptions(config: TuiConfig.Resolved) {
|
|||||||
} as const
|
} 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))
|
return formatKeySequenceExtra(parts, formatOptions(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatKeyBindings(
|
export function formatKeyBindings(
|
||||||
bindings: Parameters<typeof formatCommandBindingsExtra>[0],
|
bindings: Parameters<typeof formatCommandBindingsExtra>[0],
|
||||||
config: TuiConfig.Resolved,
|
config: FormatConfig,
|
||||||
) {
|
) {
|
||||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||||
import {
|
import {
|
||||||
RUN_COMMAND_PANEL_ROWS,
|
RUN_COMMAND_PANEL_ROWS,
|
||||||
RUN_SUBAGENT_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 { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||||
import type {
|
import type {
|
||||||
FooterKeybinds,
|
|
||||||
FooterState,
|
FooterState,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
FooterSubagentTab,
|
FooterSubagentTab,
|
||||||
@@ -23,26 +24,13 @@ import type {
|
|||||||
RunCommand,
|
RunCommand,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
|
RunTuiConfig,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
} from "@/cli/cmd/run/types"
|
} from "@/cli/cmd/run/types"
|
||||||
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
|
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
|
||||||
function bindings(...keys: string[]) {
|
const tuiConfig = createTuiResolvedConfig()
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
|
function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
|
||||||
return {
|
return {
|
||||||
@@ -143,6 +131,90 @@ function subagent(input: {
|
|||||||
} satisfies FooterSubagentTab
|
} 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 () => {
|
test("run entry content updates when live commit text changes", async () => {
|
||||||
const [commit, setCommit] = createSignal<StreamCommit>({
|
const [commit, setCommit] = createSignal<StreamCommit>({
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
@@ -204,7 +276,7 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
commands={commands}
|
commands={commands}
|
||||||
subagents={subagents}
|
subagents={subagents}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
keybinds={keybinds}
|
variantCycle="ctrl+t"
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onModel={() => {}}
|
onModel={() => {}}
|
||||||
onSubagent={() => {}}
|
onSubagent={() => {}}
|
||||||
@@ -262,7 +334,7 @@ test("direct command panel shows subagent entry when available", async () => {
|
|||||||
commands={commands}
|
commands={commands}
|
||||||
subagents={subagents}
|
subagents={subagents}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
keybinds={keybinds}
|
variantCycle="ctrl+t"
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onModel={() => {}}
|
onModel={() => {}}
|
||||||
onSubagent={() => {}}
|
onSubagent={() => {}}
|
||||||
@@ -334,6 +406,59 @@ test("direct subagent panel renders active subagents", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("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("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 () => {
|
test("direct footer shows subagent indicator while prompt is running", async () => {
|
||||||
const [state] = createSignal<FooterState>({
|
const [state] = createSignal<FooterState>({
|
||||||
phase: "running",
|
phase: "running",
|
||||||
@@ -353,10 +478,14 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
|||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
questions: [],
|
||||||
})
|
})
|
||||||
|
let offKeymap: (() => void) | undefined
|
||||||
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||||
|
|
||||||
const app = await testRender(
|
return (
|
||||||
() => (
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
<box width={100} height={8}>
|
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
@@ -371,7 +500,7 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
|||||||
view={view}
|
view={view}
|
||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
theme={RUN_THEME_FALLBACK}
|
theme={RUN_THEME_FALLBACK}
|
||||||
keybinds={keybinds}
|
tuiConfig={tuiConfig}
|
||||||
agent="opencode"
|
agent="opencode"
|
||||||
onSubmit={() => true}
|
onSubmit={() => true}
|
||||||
onPermissionReply={() => {}}
|
onPermissionReply={() => {}}
|
||||||
@@ -387,6 +516,14 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
|||||||
onLayout={() => {}}
|
onLayout={() => {}}
|
||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
/>
|
/>
|
||||||
|
</OpencodeKeymapProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={8}>
|
||||||
|
<Harness />
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -399,6 +536,9 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
|||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
|
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
|
||||||
} finally {
|
} finally {
|
||||||
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
offKeymap?.()
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,32 +7,10 @@ import {
|
|||||||
isNewCommand,
|
isNewCommand,
|
||||||
mentionTriggerIndex,
|
mentionTriggerIndex,
|
||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
printableBinding,
|
|
||||||
promptCycle,
|
|
||||||
promptHit,
|
|
||||||
promptInfo,
|
|
||||||
promptKeys,
|
|
||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
} from "@/cli/cmd/run/prompt.shared"
|
} from "@/cli/cmd/run/prompt.shared"
|
||||||
import type { RunPrompt } from "@/cli/cmd/run/types"
|
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 {
|
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
|
||||||
return { text, parts }
|
return { text, parts }
|
||||||
}
|
}
|
||||||
@@ -141,39 +119,6 @@ describe("run prompt shared", () => {
|
|||||||
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
|
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", () => {
|
test("recognizes exit commands", () => {
|
||||||
expect(isExitCommand("/exit")).toBe(true)
|
expect(isExitCommand("/exit")).toBe(true)
|
||||||
expect(isExitCommand(" /Quit ")).toBe(true)
|
expect(isExitCommand(" /Quit ")).toBe(true)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
|
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
|
||||||
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
|
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
|
||||||
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
|
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
|
||||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
|
|
||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
|
||||||
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
|
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.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
|
||||||
expect(result.leaderTimeout).toBe(2000)
|
expect(result.leader_timeout).toBe(2000)
|
||||||
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
|
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
||||||
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
|
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
|
||||||
expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
|
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
|
||||||
expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
|
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
|
||||||
expect(formatBindings(result.historyNext, result.leader)).toBe("j")
|
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
|
||||||
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
|
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
|
||||||
expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
|
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
|
||||||
expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
|
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"))
|
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
const result = await resolveFooterKeybinds()
|
const result = await resolveRunTuiConfig()
|
||||||
|
|
||||||
expect(result.leader).toBe("ctrl+x")
|
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
|
||||||
expect(result.leaderTimeout).toBe(2000)
|
expect(result.leader_timeout).toBe(2000)
|
||||||
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
|
expect(result.diff_style).toBe("auto")
|
||||||
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t")
|
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
||||||
expect(formatBindings(result.interrupt, result.leader)).toBe("esc")
|
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
|
||||||
expect(formatBindings(result.historyPrevious, result.leader)).toBe("up")
|
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
|
||||||
expect(formatBindings(result.historyNext, result.leader)).toBe("down")
|
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
|
||||||
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c")
|
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
|
||||||
expect(formatBindings(result.inputSubmit, result.leader)).toBe("return")
|
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||||
expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j")
|
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 () => {
|
test("reads diff style and falls back to auto", async () => {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.2.16",
|
"@opentui/core": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/keymap": ">=0.2.16",
|
"@opentui/keymap": "0.0.0-20260528-8a59fd68",
|
||||||
"@opentui/solid": ">=0.2.16"
|
"@opentui/solid": "0.0.0-20260528-8a59fd68"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
|
|||||||
Reference in New Issue
Block a user