refactor(tui): remove legacy keymap layer (#37206)
Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
co-authored by
Kit Langton
Dax Raad
parent
e916b99742
commit
b4a4ef0b3c
@@ -23,7 +23,7 @@ import {
|
|||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
} from "./prompt.shared"
|
} from "./prompt.shared"
|
||||||
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||||
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"
|
||||||
@@ -993,93 +993,83 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: baseBindingsEnabled(),
|
enabled: baseBindingsEnabled(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.clear",
|
id: "prompt.clear",
|
||||||
title: "Clear prompt or exit",
|
title: "Clear prompt or exit",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (requestExit()) return
|
if (requestExit()) return
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: input.prompt(),
|
enabled: input.prompt(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "session.interrupt",
|
id: "session.interrupt",
|
||||||
title: "Interrupt session",
|
title: "Interrupt session",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run() {
|
run() {
|
||||||
if (input.onInterrupt()) return
|
if (input.onInterrupt()) return
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.editor",
|
id: "prompt.editor",
|
||||||
title: "Open editor",
|
title: "Open editor",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
void openEditor()
|
void openEditor()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
priority: 1,
|
priority: 1,
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.history.previous",
|
id: "prompt.history.previous",
|
||||||
title: "Previous prompt history",
|
title: "Previous prompt history",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run(ctx: { event: KeyEvent }) {
|
run(_input: string | undefined, event?: KeyEvent) {
|
||||||
return historyCommand(-1, ctx.event)
|
if (!event) return false
|
||||||
|
return historyCommand(-1, event)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.history.next",
|
id: "prompt.history.next",
|
||||||
title: "Next prompt history",
|
title: "Next prompt history",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run(ctx: { event: KeyEvent }) {
|
run(_input: string | undefined, event?: KeyEvent) {
|
||||||
return historyCommand(1, ctx.event)
|
if (!event) return false
|
||||||
|
return historyCommand(1, event)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
|
||||||
...input.tuiConfig.keybinds.get("prompt.history.previous"),
|
|
||||||
...input.tuiConfig.keybinds.get("prompt.history.next"),
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
bindings: [
|
commands: [
|
||||||
{
|
{
|
||||||
key: "!",
|
bind: "!",
|
||||||
desc: "Shell mode",
|
title: "Shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
cmd() {
|
run() {
|
||||||
if (shell()) return false
|
if (shell()) return false
|
||||||
if (!area || area.isDestroyed) return false
|
if (!area || area.isDestroyed) return false
|
||||||
if (area.cursorOffset !== 0) return false
|
if (area.cursorOffset !== 0) return false
|
||||||
@@ -1089,21 +1079,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: input.prompt() && shell() && !visible(),
|
enabled: input.prompt() && shell() && !visible(),
|
||||||
bindings: [
|
commands: [
|
||||||
{
|
{
|
||||||
key: "escape",
|
bind: "escape",
|
||||||
desc: "Exit shell mode",
|
title: "Exit shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
cmd: () => setShellMode(false),
|
run: () => setShellMode(false),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "backspace",
|
bind: "backspace",
|
||||||
desc: "Exit shell mode",
|
title: "Exit shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
cmd() {
|
run() {
|
||||||
if (!area || area.isDestroyed) return false
|
if (!area || area.isDestroyed) return false
|
||||||
if (area.cursorOffset !== 0) return false
|
if (area.cursorOffset !== 0) return false
|
||||||
setShellMode(false)
|
setShellMode(false)
|
||||||
@@ -1112,32 +1101,31 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: input.prompt() && visible(),
|
enabled: input.prompt() && visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.prev",
|
id: "prompt.autocomplete.prev",
|
||||||
title: "Previous autocomplete item",
|
title: "Previous autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run: () => menu.move(-1),
|
run: () => menu.move(-1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.next",
|
id: "prompt.autocomplete.next",
|
||||||
title: "Next autocomplete item",
|
title: "Next autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run: () => menu.move(1),
|
run: () => menu.move(1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.hide",
|
id: "prompt.autocomplete.hide",
|
||||||
title: "Hide autocomplete",
|
title: "Hide autocomplete",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run: cancelAutocomplete,
|
run: cancelAutocomplete,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.select",
|
id: "prompt.autocomplete.select",
|
||||||
title: "Select autocomplete item",
|
title: "Select autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
if (mode() === "slash" && options().length === 0) {
|
if (mode() === "slash" && options().length === 0) {
|
||||||
hide()
|
hide()
|
||||||
@@ -1147,9 +1135,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.complete",
|
id: "prompt.autocomplete.complete",
|
||||||
title: "Complete autocomplete item",
|
title: "Complete autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
if (mode() === "slash" && options().length === 0) {
|
if (mode() === "slash" && options().length === 0) {
|
||||||
hide()
|
hide()
|
||||||
@@ -1164,13 +1152,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
|
||||||
"prompt.autocomplete.prev",
|
|
||||||
"prompt.autocomplete.next",
|
|
||||||
"prompt.autocomplete.hide",
|
|
||||||
"prompt.autocomplete.select",
|
|
||||||
"prompt.autocomplete.complete",
|
|
||||||
].flatMap((command) => input.tuiConfig.keybinds.get(command)),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const onKeyDown = (event: KeyEvent) => {
|
const onKeyDown = (event: KeyEvent) => {
|
||||||
|
|||||||
@@ -24,12 +24,11 @@
|
|||||||
// 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 KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
|
import { CliRenderEvents, type CliRenderer, 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 } from "@opencode-ai/tui/keymap"
|
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||||
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"
|
||||||
@@ -82,7 +81,6 @@ type RunFooterOptions = {
|
|||||||
first: boolean
|
first: boolean
|
||||||
history?: RunPrompt[]
|
history?: RunPrompt[]
|
||||||
theme: RunTheme
|
theme: RunTheme
|
||||||
keymap: Keymap<Renderable, KeyEvent>
|
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig
|
||||||
diffStyle: RunDiffStyle
|
diffStyle: RunDiffStyle
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
@@ -305,8 +303,8 @@ export class RunFooter implements FooterApi {
|
|||||||
const footer = this
|
const footer = this
|
||||||
void render(
|
void render(
|
||||||
() =>
|
() =>
|
||||||
createComponent(OpencodeKeymapProvider, {
|
createComponent(Keymap.Provider, {
|
||||||
keymap: options.keymap,
|
config: options.tuiConfig,
|
||||||
get children() {
|
get children() {
|
||||||
return createComponent(RunFooterView, {
|
return createComponent(RunFooterView, {
|
||||||
directory: options.directory,
|
directory: options.directory,
|
||||||
|
|||||||
@@ -27,14 +27,8 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
|
|||||||
import { RunPermissionBody } from "./footer.permission"
|
import { RunPermissionBody } from "./footer.permission"
|
||||||
import { RunQuestionBody } from "./footer.question"
|
import { RunQuestionBody } from "./footer.question"
|
||||||
import { footerWidthPolicy } from "./footer.width"
|
import { footerWidthPolicy } from "./footer.width"
|
||||||
import {
|
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||||
OPENCODE_BASE_MODE,
|
|
||||||
formatKeyBindings,
|
|
||||||
formatKeySequence,
|
|
||||||
useBindings,
|
|
||||||
useKeymapSelector,
|
|
||||||
type OpenTuiKeymap,
|
|
||||||
} from "@opencode-ai/tui/keymap"
|
|
||||||
import type {
|
import type {
|
||||||
FooterPromptRoute,
|
FooterPromptRoute,
|
||||||
FooterQueuedPrompt,
|
FooterQueuedPrompt,
|
||||||
@@ -177,75 +171,15 @@ 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 = useKeymapSelector(
|
const shortcuts = Keymap.useShortcuts()
|
||||||
(keymap: OpenTuiKeymap) =>
|
const command = () => shortcuts.get("command.palette.show") ?? ""
|
||||||
formatKeySequence(
|
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
|
||||||
keymap
|
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
|
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
|
||||||
.get("command.palette.show")?.[0]?.sequence,
|
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
|
||||||
props.tuiConfig,
|
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
|
||||||
) ?? "",
|
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
|
||||||
)
|
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
|
||||||
const subagentShortcut = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap
|
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
|
|
||||||
.get("session.child.first")?.[0]?.sequence,
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
const queuedShortcut = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap
|
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
|
|
||||||
.get("session.queued_prompts")?.[0]?.sequence,
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
const backgroundShortcut = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap
|
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
|
|
||||||
.get("session.background")?.[0]?.sequence,
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
const subagentInterruptShortcut = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap
|
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
|
|
||||||
.get("subagent.interrupt")?.[0]?.sequence,
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
const interrupt = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap
|
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
|
|
||||||
.get("session.interrupt")?.[0]?.sequence,
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
const variantCycle = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeyBindings(
|
|
||||||
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
const clearShortcut = useKeymapSelector(
|
|
||||||
(keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
|
|
||||||
?.sequence,
|
|
||||||
props.tuiConfig,
|
|
||||||
) ?? "",
|
|
||||||
)
|
|
||||||
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)
|
||||||
const exiting = createMemo(() => props.state().exit > 0)
|
const exiting = createMemo(() => props.state().exit > 0)
|
||||||
@@ -504,74 +438,62 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
props.onRequestExit?.(undefined)
|
props.onRequestExit?.(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "command.palette.show",
|
id: "command.palette.show",
|
||||||
title: "Open command palette",
|
title: "Open command palette",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run: openCommand,
|
run: openCommand,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "variant.cycle",
|
id: "variant.cycle",
|
||||||
title: "Cycle model variant",
|
title: "Cycle model variant",
|
||||||
category: "Model",
|
group: "Model",
|
||||||
run: props.onCycle,
|
run: props.onCycle,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
|
||||||
...props.tuiConfig.keybinds.get("command.palette.show"),
|
|
||||||
...props.tuiConfig.keybinds.get("variant.cycle"),
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
|
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "session.background",
|
id: "session.background",
|
||||||
title: "Background subagents",
|
title: "Background subagents",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run: () => props.onBackground?.(),
|
run: () => props.onBackground?.(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: props.tuiConfig.keybinds.get("session.background"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
|
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "session.child.first",
|
id: "session.child.first",
|
||||||
title: "View subagents",
|
title: "View subagents",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run: openSubagentMenu,
|
run: openSubagentMenu,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: props.tuiConfig.keybinds.get("session.child.first"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "session.queued_prompts",
|
id: "session.queued_prompts",
|
||||||
title: "Manage queued prompts",
|
title: "Manage queued prompts",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run: openQueuedMenu,
|
run: openQueuedMenu,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled:
|
enabled:
|
||||||
active().type === "prompt" &&
|
active().type === "prompt" &&
|
||||||
route().type === "subagent" &&
|
route().type === "subagent" &&
|
||||||
@@ -580,9 +502,10 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "subagent.interrupt",
|
id: "subagent.interrupt",
|
||||||
title: "Interrupt subagent",
|
title: "Interrupt subagent",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
|
bind: "ctrl+d",
|
||||||
run: () => {
|
run: () => {
|
||||||
const current = selectedTab()
|
const current = selectedTab()
|
||||||
if (current?.status !== "running") {
|
if (current?.status !== "running") {
|
||||||
@@ -593,7 +516,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
|
||||||
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
|
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
|
||||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
@@ -167,8 +165,6 @@ function queueSplash(
|
|||||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||||
const source = resolveInteractiveStdin()
|
const source = resolveInteractiveStdin()
|
||||||
const footerTask = import("./footer")
|
const footerTask = import("./footer")
|
||||||
let unregisterKeymap: (() => void) | undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const renderer = await createCliRenderer({
|
const renderer = await createCliRenderer({
|
||||||
stdin: source.stdin,
|
stdin: source.stdin,
|
||||||
@@ -187,8 +183,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
})
|
})
|
||||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
||||||
renderer.setBackgroundColor(theme.background)
|
renderer.setBackgroundColor(theme.background)
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
|
||||||
const state: SplashState = {
|
const state: SplashState = {
|
||||||
entry: false,
|
entry: false,
|
||||||
exit: false,
|
exit: false,
|
||||||
@@ -233,7 +227,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
history: input.history,
|
history: input.history,
|
||||||
theme,
|
theme,
|
||||||
wrote,
|
wrote,
|
||||||
keymap,
|
|
||||||
tuiConfig,
|
tuiConfig,
|
||||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
diffStyle: tuiConfig.diff_style ?? "auto",
|
||||||
onPermissionReply: input.onPermissionReply,
|
onPermissionReply: input.onPermissionReply,
|
||||||
@@ -333,7 +326,6 @@ 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)
|
||||||
if (!wroteExit) {
|
if (!wroteExit) {
|
||||||
process.stdout.write("\n")
|
process.stdout.write("\n")
|
||||||
@@ -391,7 +383,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
close,
|
close,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
unregisterKeymap?.()
|
|
||||||
source.cleanup?.()
|
source.cleanup?.()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { testRender } from "@opentui/solid"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
|
||||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { createComponent, createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { RunFooterView } from "../src/mini/footer.view"
|
import { RunFooterView } from "../src/mini/footer.view"
|
||||||
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
|
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
|
||||||
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
|
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
|
||||||
@@ -42,52 +41,43 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||||
{ terminalSuspend: true },
|
{ terminalSuspend: true },
|
||||||
)
|
)
|
||||||
let offKeymap: (() => void) | undefined
|
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
return (
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
<Keymap.Provider config={config}>
|
||||||
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
<RunFooterView
|
||||||
|
directory="/tmp"
|
||||||
return createComponent(OpencodeKeymapProvider, {
|
findFiles={async () => []}
|
||||||
keymap,
|
agents={() => []}
|
||||||
get children() {
|
references={() => []}
|
||||||
return (
|
commands={() => []}
|
||||||
<RunFooterView
|
providers={() => undefined}
|
||||||
directory="/tmp"
|
currentModel={() => undefined}
|
||||||
findFiles={async () => []}
|
variants={() => []}
|
||||||
agents={() => []}
|
currentVariant={() => undefined}
|
||||||
references={() => []}
|
state={state}
|
||||||
commands={() => []}
|
view={view}
|
||||||
providers={() => undefined}
|
subagent={subagents}
|
||||||
currentModel={() => undefined}
|
theme={() => RUN_THEME_FALLBACK}
|
||||||
variants={() => []}
|
tuiConfig={config}
|
||||||
currentVariant={() => undefined}
|
agent="opencode"
|
||||||
state={state}
|
onSubmit={() => true}
|
||||||
view={view}
|
onPermissionReply={() => {}}
|
||||||
subagent={subagents}
|
onQuestionReply={() => {}}
|
||||||
theme={() => RUN_THEME_FALLBACK}
|
onQuestionReject={() => {}}
|
||||||
tuiConfig={config}
|
onCycle={() => {}}
|
||||||
agent="opencode"
|
onInterrupt={() => false}
|
||||||
onSubmit={() => true}
|
onEditorOpen={async () => undefined}
|
||||||
onPermissionReply={() => {}}
|
onInputClear={() => {}}
|
||||||
onQuestionReply={() => {}}
|
onExit={() => {}}
|
||||||
onQuestionReject={() => {}}
|
onModelSelect={() => {}}
|
||||||
onCycle={() => {}}
|
onVariantSelect={() => {}}
|
||||||
onInterrupt={() => false}
|
onRows={() => {}}
|
||||||
onEditorOpen={async () => undefined}
|
onLayout={() => {}}
|
||||||
onInputClear={() => {}}
|
onStatus={() => {}}
|
||||||
onExit={() => {}}
|
onQueuedRemove={async () => true}
|
||||||
onModelSelect={() => {}}
|
/>
|
||||||
onVariantSelect={() => {}}
|
</Keymap.Provider>
|
||||||
onRows={() => {}}
|
)
|
||||||
onLayout={() => {}}
|
|
||||||
onStatus={() => {}}
|
|
||||||
onQueuedRemove={async () => true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
||||||
@@ -100,7 +90,6 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
offKeymap?.()
|
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender } 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 "@opencode-ai/tui/keymap"
|
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||||
import {
|
import {
|
||||||
RUN_COMMAND_PANEL_ROWS,
|
RUN_COMMAND_PANEL_ROWS,
|
||||||
RUN_SUBAGENT_PANEL_ROWS,
|
RUN_SUBAGENT_PANEL_ROWS,
|
||||||
@@ -174,15 +173,9 @@ async function renderFooter(
|
|||||||
)
|
)
|
||||||
const state = footerState(input.state)
|
const state = footerState(input.state)
|
||||||
const config = input.tuiConfig ?? tuiConfig
|
const config = input.tuiConfig ?? tuiConfig
|
||||||
let offKeymap: (() => void) | undefined
|
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
<Keymap.Provider config={config}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
@@ -215,7 +208,7 @@ async function renderFooter(
|
|||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
onQueuedRemove={async () => true}
|
||||||
/>
|
/>
|
||||||
</OpencodeKeymapProvider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +226,6 @@ async function renderFooter(
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
offKeymap?.()
|
|
||||||
offKeymap = undefined
|
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1003,14 +994,9 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
questions: [],
|
||||||
})
|
})
|
||||||
let offKeymap: (() => void) | undefined
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
<Keymap.Provider config={tuiConfig}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
@@ -1049,7 +1035,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
onQueuedRemove={async () => true}
|
||||||
/>
|
/>
|
||||||
</OpencodeKeymapProvider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,7 +1071,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
expect(frame).toContain("3 queued")
|
expect(frame).toContain("3 queued")
|
||||||
expect(frame).toContain("ctrl+b background")
|
expect(frame).toContain("ctrl+b background")
|
||||||
expect(frame).toContain("ctrl+x q 3 queued")
|
expect(frame).toContain("ctrl+x q 3 queued")
|
||||||
expect(frame).toContain("ctrl+x down subagents")
|
expect(frame).toContain("↓ subagents")
|
||||||
expect(frame).toContain("ctrl+p cmd")
|
expect(frame).toContain("ctrl+p cmd")
|
||||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||||
@@ -1099,7 +1085,6 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
offKeymap?.()
|
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1151,7 +1136,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
|
|||||||
|
|
||||||
expect(frame).toContain("GPT-5")
|
expect(frame).toContain("GPT-5")
|
||||||
expect(frame).toContain("xhigh · ctrl+p cmd")
|
expect(frame).toContain("xhigh · ctrl+p cmd")
|
||||||
expect(frame).not.toContain("ctrl+x down subagents")
|
expect(frame).not.toContain("↓ subagents")
|
||||||
} finally {
|
} finally {
|
||||||
app.cleanup()
|
app.cleanup()
|
||||||
}
|
}
|
||||||
@@ -1269,15 +1254,9 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
],
|
],
|
||||||
} satisfies QuestionRequest
|
} satisfies QuestionRequest
|
||||||
const questions: unknown[] = []
|
const questions: unknown[] = []
|
||||||
let off: (() => void) | undefined
|
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
<Keymap.Provider config={tuiConfig}>
|
||||||
<RunQuestionBody
|
<RunQuestionBody
|
||||||
request={question}
|
request={question}
|
||||||
theme={RUN_THEME_FALLBACK.footer}
|
theme={RUN_THEME_FALLBACK.footer}
|
||||||
@@ -1286,7 +1265,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
}}
|
}}
|
||||||
onReject={() => {}}
|
onReject={() => {}}
|
||||||
/>
|
/>
|
||||||
</OpencodeKeymapProvider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,7 +1290,6 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
off?.()
|
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1319,15 +1297,9 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
test("direct permission rejection submits through keymap return binding", async () => {
|
test("direct permission rejection submits through keymap return binding", async () => {
|
||||||
let text = ""
|
let text = ""
|
||||||
const submits: string[] = []
|
const submits: string[] = []
|
||||||
let off: (() => void) | undefined
|
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
<Keymap.Provider config={tuiConfig}>
|
||||||
<RejectField
|
<RejectField
|
||||||
theme={RUN_THEME_FALLBACK.footer}
|
theme={RUN_THEME_FALLBACK.footer}
|
||||||
text=""
|
text=""
|
||||||
@@ -1340,7 +1312,7 @@ test("direct permission rejection submits through keymap return binding", async
|
|||||||
}}
|
}}
|
||||||
onCancel={() => {}}
|
onCancel={() => {}}
|
||||||
/>
|
/>
|
||||||
</OpencodeKeymapProvider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,7 +1336,6 @@ test("direct permission rejection submits through keymap return binding", async
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
off?.()
|
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
ShellInfo,
|
ShellInfo,
|
||||||
SkillInfo,
|
SkillInfo,
|
||||||
} from "@opencode-ai/client"
|
} from "@opencode-ai/client"
|
||||||
import type { Renderable } from "@opentui/core"
|
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||||
import type { JSX } from "@opentui/solid"
|
import type { JSX } from "@opentui/solid"
|
||||||
|
|
||||||
interface LocationCollection<Value> {
|
interface LocationCollection<Value> {
|
||||||
@@ -139,8 +139,8 @@ export interface KeymapCommand {
|
|||||||
}
|
}
|
||||||
/** Promotes the command in discovery UI. */
|
/** Promotes the command in discovery UI. */
|
||||||
readonly suggested?: boolean | (() => boolean)
|
readonly suggested?: boolean | (() => boolean)
|
||||||
/** Executes the command. Return false to let keymap dispatch continue. */
|
/** Executes the command. Keyboard dispatch includes its event; programmatic dispatch does not. Return false to continue. */
|
||||||
readonly run: (input?: string) => void | false | Promise<void>
|
readonly run: (input?: string, event?: KeyEvent) => void | false | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeymapLayer {
|
export interface KeymapLayer {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"./editor-zed": "./src/editor-zed.ts",
|
"./editor-zed": "./src/editor-zed.ts",
|
||||||
"./runtime": "./src/runtime.tsx",
|
"./runtime": "./src/runtime.tsx",
|
||||||
"./terminal-win32": "./src/terminal-win32.ts",
|
"./terminal-win32": "./src/terminal-win32.ts",
|
||||||
"./keymap": "./src/keymap.tsx",
|
"./context/keymap": "./src/context/keymap.tsx",
|
||||||
"./prompt/content": "./src/prompt/content.ts",
|
"./prompt/content": "./src/prompt/content.ts",
|
||||||
"./prompt/display": "./src/prompt/display.ts",
|
"./prompt/display": "./src/prompt/display.ts",
|
||||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||||
|
|||||||
+39
-34
@@ -80,8 +80,7 @@ import { Config, ConfigProvider, useConfig } from "./config"
|
|||||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
||||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||||
import { CommandPaletteDialog } from "./component/command-palette"
|
import { CommandPaletteDialog } from "./component/command-palette"
|
||||||
import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap"
|
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||||
import { Keymap } from "./context/keymap"
|
|
||||||
|
|
||||||
import { DialogVariant } from "./component/dialog-variant"
|
import { DialogVariant } from "./component/dialog-variant"
|
||||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||||
@@ -416,7 +415,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = Keymap.use()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -589,7 +588,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: COMMAND_PALETTE_COMMAND,
|
name: COMMAND_PALETTE_COMMAND,
|
||||||
title: "Show command palette",
|
title: "Show command palette",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
dialog.replace(() => <CommandPaletteDialog />)
|
dialog.replace(() => <CommandPaletteDialog />)
|
||||||
},
|
},
|
||||||
@@ -621,7 +620,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: `session.quick_switch.${i + 1}`,
|
name: `session.quick_switch.${i + 1}`,
|
||||||
title: `Switch to session in quick slot ${i + 1}`,
|
title: `Switch to session in quick slot ${i + 1}`,
|
||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.session.quickSwitch(i + 1)
|
local.session.quickSwitch(i + 1)
|
||||||
},
|
},
|
||||||
@@ -641,7 +640,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "model.cycle_recent",
|
name: "model.cycle_recent",
|
||||||
title: "Model cycle",
|
title: "Model cycle",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.model.cycle(1)
|
local.model.cycle(1)
|
||||||
},
|
},
|
||||||
@@ -650,7 +649,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "model.cycle_recent_reverse",
|
name: "model.cycle_recent_reverse",
|
||||||
title: "Model cycle reverse",
|
title: "Model cycle reverse",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.model.cycle(-1)
|
local.model.cycle(-1)
|
||||||
},
|
},
|
||||||
@@ -659,7 +658,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "model.cycle_favorite",
|
name: "model.cycle_favorite",
|
||||||
title: "Favorite cycle",
|
title: "Favorite cycle",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.model.cycleFavorite(1)
|
local.model.cycleFavorite(1)
|
||||||
},
|
},
|
||||||
@@ -668,7 +667,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "model.cycle_favorite_reverse",
|
name: "model.cycle_favorite_reverse",
|
||||||
title: "Favorite cycle reverse",
|
title: "Favorite cycle reverse",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.model.cycleFavorite(-1)
|
local.model.cycleFavorite(-1)
|
||||||
},
|
},
|
||||||
@@ -695,7 +694,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "agent.cycle",
|
name: "agent.cycle",
|
||||||
title: "Agent cycle",
|
title: "Agent cycle",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.agent.move(1)
|
local.agent.move(1)
|
||||||
},
|
},
|
||||||
@@ -712,7 +711,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "variant.list",
|
name: "variant.list",
|
||||||
title: "Switch model variant",
|
title: "Switch model variant",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: local.model.variant.list().length === 0,
|
palette: local.model.variant.list().length === 0 ? undefined : (true as const),
|
||||||
slash: { name: "variants" },
|
slash: { name: "variants" },
|
||||||
run: () => {
|
run: () => {
|
||||||
if (local.model.variant.list().length === 0) {
|
if (local.model.variant.list().length === 0) {
|
||||||
@@ -729,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "agent.cycle.reverse",
|
name: "agent.cycle.reverse",
|
||||||
title: "Agent cycle reverse",
|
title: "Agent cycle reverse",
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
local.agent.move(-1)
|
local.agent.move(-1)
|
||||||
},
|
},
|
||||||
@@ -818,7 +817,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
{
|
{
|
||||||
name: "theme.switch_mode",
|
name: "theme.switch_mode",
|
||||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
setMode(mode() === "dark" ? "light" : "dark")
|
setMode(mode() === "dark" ? "light" : "dark")
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -828,7 +827,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
{
|
{
|
||||||
name: "theme.mode.lock",
|
name: "theme.mode.lock",
|
||||||
title: locked() ? "Unlock theme mode" : "Lock theme mode",
|
title: locked() ? "Unlock theme mode" : "Lock theme mode",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
if (locked()) unlock()
|
if (locked()) unlock()
|
||||||
else lock()
|
else lock()
|
||||||
@@ -883,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "terminal.suspend",
|
name: "terminal.suspend",
|
||||||
title: "Suspend terminal",
|
title: "Suspend terminal",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
enabled: process.platform !== "win32",
|
enabled: process.platform !== "win32",
|
||||||
run: () => {
|
run: () => {
|
||||||
renderer.suspend()
|
renderer.suspend()
|
||||||
@@ -895,7 +894,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "terminal.title.toggle",
|
name: "terminal.title.toggle",
|
||||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
const next = !terminalTitleEnabled()
|
const next = !terminalTitleEnabled()
|
||||||
if (!next) renderer.setTerminalTitle("")
|
if (!next) renderer.setTerminalTitle("")
|
||||||
@@ -911,7 +910,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "app.toggle.animations",
|
name: "app.toggle.animations",
|
||||||
title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations",
|
title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void config
|
void config
|
||||||
.update((draft) => {
|
.update((draft) => {
|
||||||
@@ -925,7 +924,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "app.toggle.file_context",
|
name: "app.toggle.file_context",
|
||||||
title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
|
title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void config
|
void config
|
||||||
.update((draft) => {
|
.update((draft) => {
|
||||||
@@ -939,7 +938,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "app.toggle.diffwrap",
|
name: "app.toggle.diffwrap",
|
||||||
title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void config
|
void config
|
||||||
.update((draft) => {
|
.update((draft) => {
|
||||||
@@ -956,7 +955,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "app.toggle.paste_summary",
|
name: "app.toggle.paste_summary",
|
||||||
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
|
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void config
|
void config
|
||||||
.update((draft) => {
|
.update((draft) => {
|
||||||
@@ -976,38 +975,44 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
].map((command) => ({
|
].map(
|
||||||
namespace: "palette",
|
({ name, category, ...command }) =>
|
||||||
...command,
|
({
|
||||||
})),
|
id: name,
|
||||||
|
group: category,
|
||||||
|
bind: false,
|
||||||
|
palette: true as const,
|
||||||
|
...command,
|
||||||
|
}) satisfies KeymapCommand,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
commands: appCommands(),
|
commands: appCommands(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
bindings: appBindingCommands,
|
||||||
bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
|
mode: "global",
|
||||||
|
bindings: appGlobalBindingCommands,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
enabled: () => {
|
enabled: () => {
|
||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
if (!current?.focused) return true
|
if (!current?.focused) return true
|
||||||
return current.current.text === ""
|
return current.current.text === ""
|
||||||
},
|
},
|
||||||
bindings: config.data.keybinds.get("app.exit"),
|
bindings: ["app.exit"],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
event.on("tui.command.execute", (evt, { workspace }) => {
|
event.on("tui.command.execute", (evt, { workspace }) => {
|
||||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||||
keymap.dispatchCommand(evt.data.command)
|
keymap.dispatch(evt.data.command)
|
||||||
})
|
})
|
||||||
|
|
||||||
event.on("tui.toast.show", (evt, { workspace }) => {
|
event.on("tui.toast.show", (evt, { workspace }) => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||||
import { type DialogContext } from "../ui/dialog"
|
import { type DialogContext } from "../ui/dialog"
|
||||||
import { COMMAND_PALETTE_COMMAND } from "../keymap"
|
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap"
|
||||||
import { Keymap, type KeymapCommand } from "../context/keymap"
|
|
||||||
|
|
||||||
function isSuggestedPaletteCommand(command: KeymapCommand) {
|
function isSuggestedPaletteCommand(command: KeymapCommand) {
|
||||||
const suggested = command.suggested
|
const suggested = command.suggested
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
||||||
import { useFrecency } from "../../prompt/frecency"
|
import { useFrecency } from "../../prompt/frecency"
|
||||||
import { useBindings } from "../../keymap"
|
|
||||||
import { Keymap } from "../../context/keymap"
|
import { Keymap } from "../../context/keymap"
|
||||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||||
@@ -578,48 +577,49 @@ export function Autocomplete(props: {
|
|||||||
setStore("selected", 0)
|
setStore("selected", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "autocomplete",
|
||||||
target: props.input,
|
target: props.input,
|
||||||
enabled: () => Boolean(store.visible),
|
enabled: () => Boolean(store.visible),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.prev",
|
id: "prompt.autocomplete.prev",
|
||||||
title: "Previous autocomplete item",
|
title: "Previous autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(-1)
|
move(-1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.next",
|
id: "prompt.autocomplete.next",
|
||||||
title: "Next autocomplete item",
|
title: "Next autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(1)
|
move(1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.hide",
|
id: "prompt.autocomplete.hide",
|
||||||
title: "Hide autocomplete",
|
title: "Hide autocomplete",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
hide()
|
hide()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.select",
|
id: "prompt.autocomplete.select",
|
||||||
title: "Select autocomplete item",
|
title: "Select autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
select()
|
select()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.complete",
|
id: "prompt.autocomplete.complete",
|
||||||
title: "Complete autocomplete item",
|
title: "Complete autocomplete item",
|
||||||
category: "Autocomplete",
|
group: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
const selected = options()[store.selected]
|
const selected = options()[store.selected]
|
||||||
if (selected?.isDirectory) {
|
if (selected?.isDirectory) {
|
||||||
@@ -631,13 +631,6 @@ export function Autocomplete(props: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
|
||||||
"prompt.autocomplete.prev",
|
|
||||||
"prompt.autocomplete.next",
|
|
||||||
"prompt.autocomplete.hide",
|
|
||||||
"prompt.autocomplete.select",
|
|
||||||
"prompt.autocomplete.complete",
|
|
||||||
].flatMap((command) => config.keybinds.get(command)),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function show(mode: "@" | "/") {
|
function show(mode: "@" | "/") {
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
PasteEvent,
|
PasteEvent,
|
||||||
decodePasteBytes,
|
decodePasteBytes,
|
||||||
type KeyEvent,
|
type KeyEvent,
|
||||||
type Renderable,
|
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import type { CommandContext } from "@opentui/keymap"
|
|
||||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||||
import { registerOpencodeSpinner } from "../register-spinner"
|
import { registerOpencodeSpinner } from "../register-spinner"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -46,7 +44,6 @@ import { useToast } from "../../ui/toast"
|
|||||||
import { createFadeIn } from "../../util/signal"
|
import { createFadeIn } from "../../util/signal"
|
||||||
import { DialogSkill } from "../dialog-skill"
|
import { DialogSkill } from "../dialog-skill"
|
||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
|
||||||
import { useConfig } from "../../config"
|
import { useConfig } from "../../config"
|
||||||
import { usePromptMove } from "./move"
|
import { usePromptMove } from "./move"
|
||||||
import { readLocalAttachment } from "./local-attachment"
|
import { readLocalAttachment } from "./local-attachment"
|
||||||
@@ -155,7 +152,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
let anchor: BoxRenderable
|
let anchor: BoxRenderable
|
||||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||||
|
|
||||||
const leader = useLeaderActive()
|
const leader = Keymap.useLeaderActive()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
@@ -183,10 +180,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
)
|
)
|
||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = Keymap.use()
|
||||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
const agentShortcut = Keymap.useShortcut("agent.cycle")
|
||||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
const paletteShortcut = Keymap.useShortcut("command.palette.show")
|
||||||
const liveWorkShortcut = useCommandShortcut("session.child.first")
|
const liveWorkShortcut = Keymap.useShortcut("session.child.first")
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -387,7 +384,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
title: "Clear prompt",
|
title: "Clear prompt",
|
||||||
name: "prompt.clear",
|
name: "prompt.clear",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -397,8 +394,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
title: "Submit prompt",
|
title: "Submit prompt",
|
||||||
name: "prompt.submit",
|
name: "prompt.submit",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: async () => {
|
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
event?.stopPropagation()
|
||||||
if (!input.focused) return
|
if (!input.focused) return
|
||||||
const handled = await submit()
|
const handled = await submit()
|
||||||
if (!handled) return
|
if (!handled) return
|
||||||
@@ -420,10 +419,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
title: "Paste",
|
title: "Paste",
|
||||||
name: "prompt.paste",
|
name: "prompt.paste",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
|
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||||
ctx.event.preventDefault()
|
event?.preventDefault()
|
||||||
ctx.event.stopPropagation()
|
event?.stopPropagation()
|
||||||
const content = await clipboard.read?.()
|
const content = await clipboard.read?.()
|
||||||
if (content?.mime.startsWith("image/")) {
|
if (content?.mime.startsWith("image/")) {
|
||||||
await pasteAttachment({
|
await pasteAttachment({
|
||||||
@@ -441,7 +440,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
title: "Interrupt session",
|
title: "Interrupt session",
|
||||||
name: "session.interrupt",
|
name: "session.interrupt",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
enabled: status() === "running",
|
enabled: status() === "running",
|
||||||
run: () => {
|
run: () => {
|
||||||
if (auto()?.visible) return
|
if (auto()?.visible) return
|
||||||
@@ -472,7 +471,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
title: "Background blocking tools",
|
title: "Background blocking tools",
|
||||||
name: "session.background",
|
name: "session.background",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
enabled: status() === "running",
|
enabled: status() === "running",
|
||||||
run: () => {
|
run: () => {
|
||||||
if (auto()?.visible) return
|
if (auto()?.visible) return
|
||||||
@@ -564,18 +563,24 @@ export function Prompt(props: PromptProps) {
|
|||||||
move.open()
|
move.open()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
].map((entry) => ({
|
].map(
|
||||||
namespace: "palette",
|
({ name, category, ...command }) =>
|
||||||
...entry,
|
({
|
||||||
})),
|
id: name,
|
||||||
|
group: category,
|
||||||
|
bind: false,
|
||||||
|
palette: true as const,
|
||||||
|
...command,
|
||||||
|
}) satisfies KeymapCommand,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
commands: promptCommands(),
|
commands: promptCommands(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
bindings: [
|
bindings: [
|
||||||
"prompt.submit",
|
"prompt.submit",
|
||||||
"prompt.editor",
|
"prompt.editor",
|
||||||
@@ -587,7 +592,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
"session.interrupt",
|
"session.interrupt",
|
||||||
"session.background",
|
"session.background",
|
||||||
"session.move",
|
"session.move",
|
||||||
].flatMap((command) => config.keybinds.get(command)),
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const ref: PromptRef = {
|
const ref: PromptRef = {
|
||||||
@@ -803,33 +808,40 @@ export function Prompt(props: PromptProps) {
|
|||||||
))
|
))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
].map((entry) => ({
|
].map(
|
||||||
namespace: "palette",
|
({ name, category, ...command }) =>
|
||||||
...entry,
|
({
|
||||||
})),
|
id: name,
|
||||||
|
group: category,
|
||||||
|
bind: false,
|
||||||
|
palette: true as const,
|
||||||
|
...command,
|
||||||
|
}) satisfies KeymapCommand,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
commands: stashCommands(),
|
commands: stashCommands(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: inputTarget() !== undefined && !props.disabled,
|
enabled: inputTarget() !== undefined && !props.disabled,
|
||||||
bindings: config.keybinds.get("prompt.paste"),
|
bindings: ["prompt.paste"],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||||
bindings: config.keybinds.get("prompt.clear"),
|
bindings: ["prompt.clear"],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: (() => {
|
enabled: (() => {
|
||||||
@@ -842,12 +854,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
input?.visualCursor.offset === 0
|
input?.visualCursor.offset === 0
|
||||||
)
|
)
|
||||||
})(),
|
})(),
|
||||||
bindings: [
|
commands: [
|
||||||
{
|
{
|
||||||
key: "!",
|
bind: "!",
|
||||||
desc: "Shell mode",
|
title: "Shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
cmd: () => {
|
run: () => {
|
||||||
setStore("placeholder", randomIndex(shell().length))
|
setStore("placeholder", randomIndex(shell().length))
|
||||||
setStore("mode", "shell")
|
setStore("mode", "shell")
|
||||||
},
|
},
|
||||||
@@ -856,26 +868,28 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||||
bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
|
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: (() => {
|
enabled: (() => {
|
||||||
cursorVersion()
|
cursorVersion()
|
||||||
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||||
})(),
|
})(),
|
||||||
bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
|
commands: [
|
||||||
|
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
priority: 1,
|
priority: 1,
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
@@ -885,9 +899,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
})(),
|
})(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.history.previous",
|
id: "prompt.history.previous",
|
||||||
title: "Previous prompt history",
|
title: "Previous prompt history",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (input.cursorOffset !== 0) {
|
if (input.cursorOffset !== 0) {
|
||||||
if (input.scrollY + input.visualCursor.visualRow === 0) {
|
if (input.scrollY + input.visualCursor.visualRow === 0) {
|
||||||
@@ -908,11 +922,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: config.keybinds.get("prompt.history.previous"),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useBindings(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
priority: 1,
|
priority: 1,
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
@@ -922,9 +935,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
})(),
|
})(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.history.next",
|
id: "prompt.history.next",
|
||||||
title: "Next prompt history",
|
title: "Next prompt history",
|
||||||
category: "Prompt",
|
group: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (input.cursorOffset !== input.plainText.length) {
|
if (input.cursorOffset !== input.plainText.length) {
|
||||||
if (
|
if (
|
||||||
@@ -948,7 +961,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: config.keybinds.get("prompt.history.next"),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1429,7 +1441,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||||
if (!pastedContent) {
|
if (!pastedContent) {
|
||||||
keymap.dispatchCommand("prompt.paste")
|
keymap.dispatch("prompt.paste")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,10 +1601,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Show
|
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||||
when={!props.hint && locationLabel()}
|
|
||||||
fallback={props.hint ?? <text />}
|
|
||||||
>
|
|
||||||
{(location) => (
|
{(location) => (
|
||||||
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||||
{location()}
|
{location()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
|
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
|
||||||
import { InputRenderable, TextareaRenderable } from "@opentui/core"
|
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
|
||||||
import { stringifyKeyStroke } from "@opentui/keymap"
|
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
|
||||||
import {
|
import {
|
||||||
registerBackspacePopsPendingSequence,
|
registerBackspacePopsPendingSequence,
|
||||||
registerBaseLayoutFallback,
|
registerBaseLayoutFallback,
|
||||||
@@ -32,17 +32,27 @@ const MODE = { key: "opencode.mode", base: "base" } as const
|
|||||||
|
|
||||||
type OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
|
type OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
|
||||||
type Mode = ReturnType<typeof createMode>
|
type Mode = ReturnType<typeof createMode>
|
||||||
|
type KeymapConfig = {
|
||||||
|
readonly keybinds: {
|
||||||
|
get(command: string): readonly Binding<Renderable, KeyEvent>[]
|
||||||
|
}
|
||||||
|
readonly leader?: { readonly timeout: number }
|
||||||
|
readonly leader_timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
|
||||||
|
|
||||||
const Context = createContext<{
|
const Context = createContext<{
|
||||||
readonly keymap: OpenTuiKeymap
|
readonly keymap: OpenTuiKeymap
|
||||||
|
readonly config: KeymapConfig
|
||||||
readonly mode: Mode
|
readonly mode: Mode
|
||||||
readonly dispatch: (id: string, input?: string) => void
|
readonly dispatch: (id: string, input?: string) => void
|
||||||
readonly input: (id: string) => string | undefined
|
readonly input: (id: string) => string | undefined
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function Provider(props: ParentProps) {
|
function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const config = useConfig()
|
const config: KeymapConfig = props.config ?? useConfig().data
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
const mode = createMode(keymap)
|
const mode = createMode(keymap)
|
||||||
let invocation: { readonly id: string; readonly input?: string } | undefined
|
let invocation: { readonly id: string; readonly input?: string } | undefined
|
||||||
@@ -111,16 +121,16 @@ function Provider(props: ParentProps) {
|
|||||||
"input.delete.word.backward",
|
"input.delete.word.backward",
|
||||||
"input.select.all",
|
"input.select.all",
|
||||||
"input.submit",
|
"input.submit",
|
||||||
].flatMap((command) => config.data.keybinds.get(command)),
|
].flatMap((command) => config.keybinds.get(command)),
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
const leader = config.data.keybinds.get("leader")?.[0]?.key
|
const leader = config.keybinds.get("leader")?.[0]?.key
|
||||||
if (leader) {
|
if (leader) {
|
||||||
dispose.push(
|
dispose.push(
|
||||||
registerTimedLeader(keymap, {
|
registerTimedLeader(keymap, {
|
||||||
trigger: leader,
|
trigger: leader,
|
||||||
name: "leader",
|
name: "leader",
|
||||||
timeoutMs: config.data.leader.timeout,
|
timeoutMs: config.leader?.timeout ?? ("leader_timeout" in config ? config.leader_timeout : undefined) ?? 2000,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,7 +141,13 @@ function Provider(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<KeymapProvider keymap={keymap}>
|
<KeymapProvider keymap={keymap}>
|
||||||
<Context.Provider
|
<Context.Provider
|
||||||
value={{ keymap, mode, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined) }}
|
value={{
|
||||||
|
keymap,
|
||||||
|
config,
|
||||||
|
mode,
|
||||||
|
dispatch,
|
||||||
|
input: (id) => (invocation?.id === id ? invocation.input : undefined),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
@@ -151,6 +167,8 @@ export interface Keymap {
|
|||||||
/** Pushes a mode until the returned cleanup is called. */
|
/** Pushes a mode until the returned cleanup is called. */
|
||||||
push(mode: string): () => void
|
push(mode: string): () => void
|
||||||
}
|
}
|
||||||
|
/** Registers a low-level keymap interceptor. */
|
||||||
|
intercept: OpenTuiKeymap["intercept"]
|
||||||
}
|
}
|
||||||
|
|
||||||
function use(): Keymap {
|
function use(): Keymap {
|
||||||
@@ -160,12 +178,12 @@ function use(): Keymap {
|
|||||||
value.dispatch(id, input)
|
value.dispatch(id, input)
|
||||||
},
|
},
|
||||||
mode: value.mode,
|
mode: value.mode,
|
||||||
|
intercept: value.keymap.intercept.bind(value.keymap),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLayer(input: () => KeymapLayer) {
|
function createLayer(input: () => KeymapLayer) {
|
||||||
const value = useValue()
|
const value = useValue()
|
||||||
const config = useConfig()
|
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
const layer = input()
|
const layer = input()
|
||||||
const { commands, bindings, mode, ...options } = layer
|
const { commands, bindings, mode, ...options } = layer
|
||||||
@@ -199,7 +217,7 @@ function createLayer(input: () => KeymapLayer) {
|
|||||||
...definition,
|
...definition,
|
||||||
name: id,
|
name: id,
|
||||||
opencode: command,
|
opencode: command,
|
||||||
run: () => run(value.input(id)),
|
run: (context: CommandContext<Renderable, KeyEvent>) => run(value.input(id), context.event),
|
||||||
...(description === undefined ? {} : { desc: description }),
|
...(description === undefined ? {} : { desc: description }),
|
||||||
...(group === undefined ? {} : { category: group }),
|
...(group === undefined ? {} : { category: group }),
|
||||||
...(palette === undefined ? {} : { namespace: "palette" }),
|
...(palette === undefined ? {} : { namespace: "palette" }),
|
||||||
@@ -220,20 +238,19 @@ function createLayer(input: () => KeymapLayer) {
|
|||||||
})),
|
})),
|
||||||
...grouped.named.flatMap((command) => {
|
...grouped.named.flatMap((command) => {
|
||||||
if (command.bind === false) return []
|
if (command.bind === false) return []
|
||||||
const configured = config.data.keybinds.get(command.id)
|
const configured = value.config.keybinds.get(command.id)
|
||||||
if (configured.length) return configured
|
if (configured.length) return configured
|
||||||
if (typeof command.bind !== "string") return []
|
if (typeof command.bind !== "string") return []
|
||||||
return [{ key: command.bind, cmd: command.id }]
|
return [{ key: command.bind, cmd: command.id }]
|
||||||
}),
|
}),
|
||||||
...(bindings ?? []).flatMap((id) => config.data.keybinds.get(id)),
|
...(bindings ?? []).flatMap((id) => value.config.keybinds.get(id)),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useShortcuts() {
|
function useShortcuts() {
|
||||||
useValue()
|
const value = useValue()
|
||||||
const config = useConfig()
|
|
||||||
const shortcuts = useKeymapSelector((keymap) => {
|
const shortcuts = useKeymapSelector((keymap) => {
|
||||||
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
|
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
|
||||||
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
|
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
|
||||||
@@ -241,8 +258,8 @@ function useShortcuts() {
|
|||||||
commands.map((id) => [
|
commands.map((id) => [
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)),
|
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)),
|
||||||
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)),
|
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -257,6 +274,16 @@ function useShortcuts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useShortcut(id: string) {
|
||||||
|
const shortcuts = useShortcuts()
|
||||||
|
return () => shortcuts.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLeaderActive() {
|
||||||
|
const pending = usePendingSequence()
|
||||||
|
return () => pending()[0]?.tokenName === "leader"
|
||||||
|
}
|
||||||
|
|
||||||
function useCommands(): Accessor<readonly KeymapCommand[]> {
|
function useCommands(): Accessor<readonly KeymapCommand[]> {
|
||||||
const value = useValue()
|
const value = useValue()
|
||||||
return useKeymapSelector((keymap) =>
|
return useKeymapSelector((keymap) =>
|
||||||
@@ -312,6 +339,8 @@ export const Keymap = {
|
|||||||
use,
|
use,
|
||||||
createLayer,
|
createLayer,
|
||||||
useShortcuts,
|
useShortcuts,
|
||||||
|
useShortcut,
|
||||||
|
useLeaderActive,
|
||||||
useCommands,
|
useCommands,
|
||||||
usePendingSequence,
|
usePendingSequence,
|
||||||
useActiveKeys,
|
useActiveKeys,
|
||||||
@@ -355,7 +384,7 @@ function createMode(keymap: OpenTuiKeymap) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOptions(config: ReturnType<typeof useConfig>["data"]) {
|
function formatOptions(config: KeymapConfig) {
|
||||||
const leader = config.keybinds.get("leader")?.[0]?.key
|
const leader = config.keybinds.get("leader")?.[0]?.key
|
||||||
return {
|
return {
|
||||||
tokenDisplay: {
|
tokenDisplay: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||||
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { useBindings } from "../../keymap"
|
import { Keymap } from "../../context/keymap"
|
||||||
|
|
||||||
const id = "internal:plugin-manager"
|
const id = "internal:plugin-manager"
|
||||||
|
|
||||||
@@ -39,9 +39,19 @@ function Install(props: { api: TuiPluginApi }) {
|
|||||||
const [global, setGlobal] = createSignal(false)
|
const [global, setGlobal] = createSignal(false)
|
||||||
const [busy, setBusy] = createSignal(false)
|
const [busy, setBusy] = createSignal(false)
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "modal",
|
||||||
enabled: !busy(),
|
enabled: !busy(),
|
||||||
bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }],
|
commands: [
|
||||||
|
{
|
||||||
|
bind: "tab",
|
||||||
|
title: "Toggle install scope",
|
||||||
|
group: "Plugins",
|
||||||
|
run: () => {
|
||||||
|
setGlobal((value) => !value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
|
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { useBindings, useKeymapSelector } from "../../keymap"
|
import { Keymap } from "../../context/keymap"
|
||||||
import type { ActiveKey } from "@opentui/keymap"
|
import type { ActiveKey } from "@opentui/keymap"
|
||||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
import type { BuiltinTuiPlugin } from "../builtins"
|
import type { BuiltinTuiPlugin } from "../builtins"
|
||||||
@@ -153,12 +153,9 @@ function grouped(entries: Entry[]): Group[] {
|
|||||||
.toSorted((a, b) => a.label.localeCompare(b.label))
|
.toSorted((a, b) => a.label.localeCompare(b.label))
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandShortcut(api: TuiPluginApi, name: string) {
|
function commandShortcut(_api: TuiPluginApi, name: string) {
|
||||||
return useKeymapSelector((keymap) =>
|
const shortcuts = Keymap.useShortcuts()
|
||||||
api.keys.formatSequence(
|
return () => shortcuts.get(name) ?? ""
|
||||||
keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function layout(value: unknown): Layout {
|
function layout(value: unknown): Layout {
|
||||||
@@ -189,8 +186,8 @@ function WhichKeyPanel(props: {
|
|||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const [offset, setOffset] = createSignal(0)
|
const [offset, setOffset] = createSignal(0)
|
||||||
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
|
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
|
||||||
const pending = useKeymapSelector((keymap) => keymap.getPendingSequence())
|
const pending = Keymap.usePendingSequence()
|
||||||
const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
|
const active = Keymap.useActiveKeys()
|
||||||
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
|
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
|
||||||
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
|
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
|
||||||
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
|
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
|
||||||
@@ -281,86 +278,92 @@ function WhichKeyPanel(props: {
|
|||||||
setOffset(0)
|
setOffset(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
enabled: visible(),
|
enabled: visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: command.groupPrevious,
|
id: command.groupPrevious,
|
||||||
|
bind: false,
|
||||||
title: "Previous key binding group",
|
title: "Previous key binding group",
|
||||||
desc: "Show the previous which-key group",
|
description: "Show the previous which-key group",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
moveGroup(-1)
|
moveGroup(-1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.groupNext,
|
id: command.groupNext,
|
||||||
|
bind: false,
|
||||||
title: "Next key binding group",
|
title: "Next key binding group",
|
||||||
desc: "Show the next which-key group",
|
description: "Show the next which-key group",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
moveGroup(1)
|
moveGroup(1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.scrollUp,
|
id: command.scrollUp,
|
||||||
|
bind: false,
|
||||||
title: "Scroll key bindings up",
|
title: "Scroll key bindings up",
|
||||||
desc: "Scroll the which-key panel up",
|
description: "Scroll the which-key panel up",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
scroll(-columns())
|
scroll(-columns())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.scrollDown,
|
id: command.scrollDown,
|
||||||
|
bind: false,
|
||||||
title: "Scroll key bindings down",
|
title: "Scroll key bindings down",
|
||||||
desc: "Scroll the which-key panel down",
|
description: "Scroll the which-key panel down",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
scroll(columns())
|
scroll(columns())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.pageUp,
|
id: command.pageUp,
|
||||||
|
bind: false,
|
||||||
title: "Page key bindings up",
|
title: "Page key bindings up",
|
||||||
desc: "Page the which-key panel up",
|
description: "Page the which-key panel up",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
scroll(-pageSize())
|
scroll(-pageSize())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.pageDown,
|
id: command.pageDown,
|
||||||
|
bind: false,
|
||||||
title: "Page key bindings down",
|
title: "Page key bindings down",
|
||||||
desc: "Page the which-key panel down",
|
description: "Page the which-key panel down",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
scroll(pageSize())
|
scroll(pageSize())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.home,
|
id: command.home,
|
||||||
|
bind: false,
|
||||||
title: "First key binding",
|
title: "First key binding",
|
||||||
desc: "Jump to the first which-key binding",
|
description: "Jump to the first which-key binding",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
setOffset(0)
|
setOffset(0)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: command.end,
|
id: command.end,
|
||||||
|
bind: false,
|
||||||
title: "Last key binding",
|
title: "Last key binding",
|
||||||
desc: "Jump to the last which-key binding",
|
description: "Jump to the last which-key binding",
|
||||||
category: "System",
|
group: "System",
|
||||||
run() {
|
run() {
|
||||||
setOffset(maxOffset())
|
setOffset(maxOffset())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: (pendingMode() ? scrollCommands : panelCommands).flatMap((command) =>
|
bindings: pendingMode() ? scrollCommands : panelCommands,
|
||||||
props.api.tuiConfig.keybinds.get(command),
|
|
||||||
),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
import { InputRenderable, TextareaRenderable, type CliRenderer, type KeyEvent, type Renderable } from "@opentui/core"
|
|
||||||
import {
|
|
||||||
registerBackspacePopsPendingSequence,
|
|
||||||
registerBaseLayoutFallback,
|
|
||||||
registerCommaBindings,
|
|
||||||
registerEscapeClearsPendingSequence,
|
|
||||||
registerManagedTextareaLayer,
|
|
||||||
registerTimedLeader,
|
|
||||||
} from "@opentui/keymap/addons/opentui"
|
|
||||||
import { stringifyKeyStroke, type Binding } from "@opentui/keymap"
|
|
||||||
import {
|
|
||||||
formatCommandBindings as formatCommandBindingsExtra,
|
|
||||||
formatKeySequence as formatKeySequenceExtra,
|
|
||||||
} from "@opentui/keymap/extras"
|
|
||||||
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
import { useConfig } from "./config"
|
|
||||||
import { TuiKeybind } from "./config/keybind"
|
|
||||||
import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context"
|
|
||||||
|
|
||||||
declare module "@opentui/keymap" {
|
|
||||||
interface Command {
|
|
||||||
opencode?: KeymapCommand
|
|
||||||
slash?: {
|
|
||||||
name: string
|
|
||||||
aliases?: string[]
|
|
||||||
arguments?: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LEADER_TOKEN = "leader"
|
|
||||||
export const OPENCODE_BASE_MODE = "base"
|
|
||||||
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
|
|
||||||
|
|
||||||
const OPENCODE_MODE_KEY = "opencode.mode"
|
|
||||||
|
|
||||||
export { useBindings, useKeymapSelector }
|
|
||||||
|
|
||||||
export const OpencodeKeymapProvider = KeymapProvider
|
|
||||||
export const useOpencodeKeymap = useKeymap
|
|
||||||
|
|
||||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
|
||||||
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
|
||||||
type BindingLookup = {
|
|
||||||
get(command: string): readonly Binding<Renderable, KeyEvent>[]
|
|
||||||
}
|
|
||||||
type FormatConfig = { keybinds: BindingLookup }
|
|
||||||
type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
|
|
||||||
|
|
||||||
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
|
||||||
|
|
||||||
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
|
|
||||||
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
|
|
||||||
|
|
||||||
const offFields = keymap.registerLayerFields({
|
|
||||||
mode(value, ctx) {
|
|
||||||
ctx.require(OPENCODE_MODE_KEY, value)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const stack: { id: symbol; mode: string }[] = []
|
|
||||||
let disposed = false
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stackApi = {
|
|
||||||
current() {
|
|
||||||
return stack.at(-1)?.mode ?? OPENCODE_BASE_MODE
|
|
||||||
},
|
|
||||||
push(mode: string) {
|
|
||||||
if (disposed) return () => {}
|
|
||||||
const id = Symbol(mode)
|
|
||||||
let active = true
|
|
||||||
stack.push({ id, mode })
|
|
||||||
update()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!active) return
|
|
||||||
active = false
|
|
||||||
const index = stack.findIndex((item) => item.id === id)
|
|
||||||
if (index !== -1) stack.splice(index, 1)
|
|
||||||
update()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
if (disposed) return
|
|
||||||
disposed = true
|
|
||||||
stack.length = 0
|
|
||||||
offFields()
|
|
||||||
keymap.setData(OPENCODE_MODE_KEY, undefined)
|
|
||||||
modeStacks.delete(keymap)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
modeStacks.set(keymap, stackApi)
|
|
||||||
return stackApi
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpencodeModeStack() {
|
|
||||||
return getOpencodeModeStack(useOpencodeKeymap())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOpencodeModeStack(keymap: OpenTuiKeymap) {
|
|
||||||
const value = modeStacks.get(keymap)
|
|
||||||
if (!value) throw new Error("Opencode mode stack is not registered for this keymap")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY_ALIASES = {
|
|
||||||
enter: "return",
|
|
||||||
esc: "escape",
|
|
||||||
pgdown: "pagedown",
|
|
||||||
pgup: "pageup",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
function expandKeyAliases(input: string) {
|
|
||||||
const result = Object.entries(KEY_ALIASES).reduce(
|
|
||||||
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
|
|
||||||
input,
|
|
||||||
)
|
|
||||||
if (result === input) return
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerKeyAliases(keymap: OpenTuiKeymap) {
|
|
||||||
return keymap.appendBindingExpander((ctx) => {
|
|
||||||
const key = expandKeyAliases(ctx.input)
|
|
||||||
if (!key) return
|
|
||||||
return [{ key, displays: ctx.displays }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCommands = [
|
|
||||||
"input.move.left",
|
|
||||||
"input.move.right",
|
|
||||||
"input.move.up",
|
|
||||||
"input.move.down",
|
|
||||||
"input.select.left",
|
|
||||||
"input.select.right",
|
|
||||||
"input.select.up",
|
|
||||||
"input.select.down",
|
|
||||||
"input.line.home",
|
|
||||||
"input.line.end",
|
|
||||||
"input.select.line.home",
|
|
||||||
"input.select.line.end",
|
|
||||||
"input.visual.line.home",
|
|
||||||
"input.visual.line.end",
|
|
||||||
"input.select.visual.line.home",
|
|
||||||
"input.select.visual.line.end",
|
|
||||||
"input.buffer.home",
|
|
||||||
"input.buffer.end",
|
|
||||||
"input.select.buffer.home",
|
|
||||||
"input.select.buffer.end",
|
|
||||||
"input.delete.line",
|
|
||||||
"input.delete.to.line.end",
|
|
||||||
"input.delete.to.line.start",
|
|
||||||
"input.backspace",
|
|
||||||
"input.delete",
|
|
||||||
"input.newline",
|
|
||||||
"input.undo",
|
|
||||||
"input.redo",
|
|
||||||
"input.word.forward",
|
|
||||||
"input.word.backward",
|
|
||||||
"input.select.word.forward",
|
|
||||||
"input.select.word.backward",
|
|
||||||
"input.delete.word.forward",
|
|
||||||
"input.delete.word.backward",
|
|
||||||
"input.select.all",
|
|
||||||
"input.submit",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function hasManagedTextareaFocus(renderer: CliRenderer) {
|
|
||||||
const editor = renderer.currentFocusedEditor
|
|
||||||
return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
|
|
||||||
}
|
|
||||||
|
|
||||||
function leaderDisplay(config: FormatConfig) {
|
|
||||||
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
|
||||||
if (!key) return TuiKeybind.LeaderDefault
|
|
||||||
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
function leaderKey(config: FormatConfig) {
|
|
||||||
return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatOptions(config: FormatConfig) {
|
|
||||||
return {
|
|
||||||
tokenDisplay: {
|
|
||||||
[LEADER_TOKEN]: leaderDisplay(config),
|
|
||||||
},
|
|
||||||
keyNameAliases: {
|
|
||||||
up: "↑",
|
|
||||||
down: "↓",
|
|
||||||
left: "←",
|
|
||||||
right: "→",
|
|
||||||
pageup: "pgup",
|
|
||||||
pagedown: "pgdn",
|
|
||||||
delete: "del",
|
|
||||||
},
|
|
||||||
modifierAliases: {
|
|
||||||
meta: "alt",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
|
|
||||||
return formatKeySequenceExtra(parts, formatOptions(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatKeyBindings(bindings: Parameters<typeof formatCommandBindingsExtra>[0], config: FormatConfig) {
|
|
||||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) {
|
|
||||||
const modeStack = createOpencodeModeStack(keymap)
|
|
||||||
const offCommaBindings = registerCommaBindings(keymap)
|
|
||||||
const offAliasExpander = registerKeyAliases(keymap)
|
|
||||||
const offBaseLayout = registerBaseLayoutFallback(keymap)
|
|
||||||
const leader = leaderKey(config)
|
|
||||||
const offLeader = leader
|
|
||||||
? registerTimedLeader(keymap, {
|
|
||||||
trigger: leader,
|
|
||||||
name: LEADER_TOKEN,
|
|
||||||
timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout,
|
|
||||||
})
|
|
||||||
: () => {}
|
|
||||||
const offEscape = registerEscapeClearsPendingSequence(keymap)
|
|
||||||
const offBackspace = registerBackspacePopsPendingSequence(keymap)
|
|
||||||
const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
|
|
||||||
enabled: () => hasManagedTextareaFocus(renderer),
|
|
||||||
bindings: inputCommands.flatMap((command) => config.keybinds.get(command)),
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
offInputBindings()
|
|
||||||
offBackspace()
|
|
||||||
offEscape()
|
|
||||||
offLeader()
|
|
||||||
offAliasExpander()
|
|
||||||
offBaseLayout()
|
|
||||||
offCommaBindings()
|
|
||||||
modeStack.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLeaderActive(): Accessor<boolean> {
|
|
||||||
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommandShortcut(command: string): Accessor<string> {
|
|
||||||
const config = useConfig().data
|
|
||||||
return useKeymapSelector((keymap: OpenTuiKeymap) =>
|
|
||||||
formatKeySequence(
|
|
||||||
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
|
|
||||||
config,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,7 @@ import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../con
|
|||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||||
import { usePluginRuntime } from "../../plugin/runtime"
|
import { usePluginRuntime } from "../../plugin/runtime"
|
||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||||
@@ -317,10 +317,10 @@ export function Session() {
|
|||||||
|
|
||||||
const globalCommands = [
|
const globalCommands = [
|
||||||
{
|
{
|
||||||
name: "session.page.up",
|
id: "session.page.up",
|
||||||
title: "Page up",
|
title: "Page up",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-scroll.height / 2)
|
scroll.scrollBy(-scroll.height / 2)
|
||||||
@@ -328,10 +328,10 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.page.down",
|
id: "session.page.down",
|
||||||
title: "Page down",
|
title: "Page down",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(scroll.height / 2)
|
scroll.scrollBy(scroll.height / 2)
|
||||||
@@ -339,10 +339,10 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.line.up",
|
id: "session.line.up",
|
||||||
title: "Line up",
|
title: "Line up",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-1)
|
scroll.scrollBy(-1)
|
||||||
@@ -350,10 +350,10 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.line.down",
|
id: "session.line.down",
|
||||||
title: "Line down",
|
title: "Line down",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(1)
|
scroll.scrollBy(1)
|
||||||
@@ -361,10 +361,10 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.half.page.up",
|
id: "session.half.page.up",
|
||||||
title: "Half page up",
|
title: "Half page up",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-scroll.height / 4)
|
scroll.scrollBy(-scroll.height / 4)
|
||||||
@@ -372,10 +372,10 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.half.page.down",
|
id: "session.half.page.down",
|
||||||
title: "Half page down",
|
title: "Half page down",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(scroll.height / 4)
|
scroll.scrollBy(scroll.height / 4)
|
||||||
@@ -386,10 +386,10 @@ export function Session() {
|
|||||||
|
|
||||||
const baseAndUnfocusedCommands = [
|
const baseAndUnfocusedCommands = [
|
||||||
{
|
{
|
||||||
name: "session.first",
|
id: "session.first",
|
||||||
title: "First message",
|
title: "First message",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(0)
|
scroll.scrollTo(0)
|
||||||
@@ -397,10 +397,10 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.last",
|
id: "session.last",
|
||||||
title: "Last message",
|
title: "Last message",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
@@ -412,30 +412,30 @@ export function Session() {
|
|||||||
const baseCommands = createMemo(() => [
|
const baseCommands = createMemo(() => [
|
||||||
{
|
{
|
||||||
title: "Share session",
|
title: "Share session",
|
||||||
name: "session.share",
|
id: "session.share",
|
||||||
suggested: route.type === "session",
|
suggested: route.type === "session",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: { name: "share" },
|
slash: { name: "share" },
|
||||||
run: () => unavailable("Sharing"),
|
run: () => unavailable("Sharing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Rename session",
|
title: "Rename session",
|
||||||
name: "session.rename",
|
id: "session.rename",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: { name: "rename" },
|
slash: { name: "rename" },
|
||||||
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
|
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Jump to message",
|
title: "Jump to message",
|
||||||
name: "session.timeline",
|
id: "session.timeline",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: { name: "timeline" },
|
slash: { name: "timeline" },
|
||||||
run: () => unavailable("The message timeline"),
|
run: () => unavailable("The message timeline"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Fork session",
|
title: "Fork session",
|
||||||
name: "session.fork",
|
id: "session.fork",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: { name: "fork" },
|
slash: { name: "fork" },
|
||||||
run: () => {
|
run: () => {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
@@ -451,8 +451,8 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Compact session",
|
title: "Compact session",
|
||||||
name: "session.compact",
|
id: "session.compact",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: {
|
slash: {
|
||||||
name: "compact",
|
name: "compact",
|
||||||
aliases: ["summarize"],
|
aliases: ["summarize"],
|
||||||
@@ -464,16 +464,16 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Unshare session",
|
title: "Unshare session",
|
||||||
name: "session.unshare",
|
id: "session.unshare",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
slash: { name: "unshare" },
|
slash: { name: "unshare" },
|
||||||
run: () => unavailable("Unsharing"),
|
run: () => unavailable("Unsharing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Undo previous message",
|
title: "Undo previous message",
|
||||||
name: "session.undo",
|
id: "session.undo",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: { name: "undo" },
|
slash: { name: "undo" },
|
||||||
run: () => {
|
run: () => {
|
||||||
const boundary = session()?.revert?.messageID
|
const boundary = session()?.revert?.messageID
|
||||||
@@ -508,8 +508,8 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Redo",
|
title: "Redo",
|
||||||
name: "session.redo",
|
id: "session.redo",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
enabled: !!session()?.revert?.messageID,
|
enabled: !!session()?.revert?.messageID,
|
||||||
slash: { name: "redo" },
|
slash: { name: "redo" },
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -525,8 +525,8 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||||
name: "session.sidebar.toggle",
|
id: "session.sidebar.toggle",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run: () => {
|
run: () => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const isVisible = sidebarVisible()
|
const isVisible = sidebarVisible()
|
||||||
@@ -546,9 +546,9 @@ export function Session() {
|
|||||||
if (next === "hide") return "Collapse thinking"
|
if (next === "hide") return "Collapse thinking"
|
||||||
return "Expand thinking"
|
return "Expand thinking"
|
||||||
})(),
|
})(),
|
||||||
name: "session.toggle.thinking",
|
id: "session.toggle.thinking",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
slash: {
|
slash: {
|
||||||
name: "thinking",
|
name: "thinking",
|
||||||
aliases: ["toggle-thinking"],
|
aliases: ["toggle-thinking"],
|
||||||
@@ -564,9 +564,9 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Toggle session scrollbar",
|
title: "Toggle session scrollbar",
|
||||||
name: "session.toggle.scrollbar",
|
id: "session.toggle.scrollbar",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void configState
|
void configState
|
||||||
.update((draft) => {
|
.update((draft) => {
|
||||||
@@ -578,9 +578,9 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
|
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
|
||||||
name: "session.toggle.exploration_grouping",
|
id: "session.toggle.exploration_grouping",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void configState
|
void configState
|
||||||
.update((draft) => {
|
.update((draft) => {
|
||||||
@@ -592,9 +592,9 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Jump to last user message",
|
title: "Jump to last user message",
|
||||||
name: "session.messages_last_user",
|
id: "session.messages_last_user",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
const messages = data.session.message.list(route.sessionID)
|
const messages = data.session.message.list(route.sessionID)
|
||||||
if (!messages || !messages.length) return
|
if (!messages || !messages.length) return
|
||||||
@@ -612,36 +612,36 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next message",
|
title: "Next message",
|
||||||
name: "session.message.next",
|
id: "session.message.next",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => scrollToMessage("next", dialog),
|
run: () => scrollToMessage("next", dialog),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Previous message",
|
title: "Previous message",
|
||||||
name: "session.message.previous",
|
id: "session.message.previous",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => scrollToMessage("prev", dialog),
|
run: () => scrollToMessage("prev", dialog),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next user message",
|
title: "Next user message",
|
||||||
name: "session.message.user.next",
|
id: "session.message.user.next",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => scrollToMessage("next", dialog, true),
|
run: () => scrollToMessage("next", dialog, true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Previous user message",
|
title: "Previous user message",
|
||||||
name: "session.message.user.previous",
|
id: "session.message.user.previous",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => scrollToMessage("prev", dialog, true),
|
run: () => scrollToMessage("prev", dialog, true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Copy last assistant message",
|
title: "Copy last assistant message",
|
||||||
name: "messages.copy",
|
id: "messages.copy",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run: () => {
|
run: () => {
|
||||||
const revertID = session()?.revert?.messageID
|
const revertID = session()?.revert?.messageID
|
||||||
const lastAssistantMessage = messages().findLast(
|
const lastAssistantMessage = messages().findLast(
|
||||||
@@ -682,8 +682,8 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Copy session transcript",
|
title: "Copy session transcript",
|
||||||
name: "session.copy",
|
id: "session.copy",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: {
|
slash: {
|
||||||
name: "copy",
|
name: "copy",
|
||||||
},
|
},
|
||||||
@@ -702,8 +702,8 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Export session transcript",
|
title: "Export session transcript",
|
||||||
name: "session.export",
|
id: "session.export",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
slash: {
|
slash: {
|
||||||
name: "export",
|
name: "export",
|
||||||
},
|
},
|
||||||
@@ -772,9 +772,9 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Background blocking tools",
|
title: "Background blocking tools",
|
||||||
name: "session.background",
|
id: "session.background",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
void client.api.session.background({ sessionID: route.sessionID })
|
void client.api.session.background({ sessionID: route.sessionID })
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -782,8 +782,8 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Toggle subagent picker",
|
title: "Toggle subagent picker",
|
||||||
name: "session.child.first",
|
id: "session.child.first",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
run: () => {
|
run: () => {
|
||||||
if (composer.open || session()?.parentID) setComposer("open", false)
|
if (composer.open || session()?.parentID) setComposer("open", false)
|
||||||
else setComposer("open", true)
|
else setComposer("open", true)
|
||||||
@@ -792,9 +792,9 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Go to parent session",
|
title: "Go to parent session",
|
||||||
name: "session.parent",
|
id: "session.parent",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
enabled: !!session()?.parentID,
|
enabled: !!session()?.parentID,
|
||||||
run: () => {
|
run: () => {
|
||||||
const parentID = session()?.parentID
|
const parentID = session()?.parentID
|
||||||
@@ -809,41 +809,46 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next subagent",
|
title: "Next subagent",
|
||||||
name: "session.child.next",
|
id: "session.child.next",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
enabled: !!session()?.parentID,
|
enabled: !!session()?.parentID,
|
||||||
run: () => unavailable("Subagent navigation"),
|
run: () => unavailable("Subagent navigation"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Previous subagent",
|
title: "Previous subagent",
|
||||||
name: "session.child.previous",
|
id: "session.child.previous",
|
||||||
category: "Session",
|
group: "Session",
|
||||||
hidden: true,
|
palette: undefined,
|
||||||
enabled: !!session()?.parentID,
|
enabled: !!session()?.parentID,
|
||||||
run: () => unavailable("Subagent navigation"),
|
run: () => unavailable("Subagent navigation"),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
useBindings(() => ({
|
const commands = createMemo(() =>
|
||||||
commands: [...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map((command) => ({
|
[...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map(
|
||||||
namespace: "palette",
|
(command) =>
|
||||||
...command,
|
({
|
||||||
})),
|
bind: false,
|
||||||
|
palette: true as const,
|
||||||
|
...command,
|
||||||
|
}) satisfies KeymapCommand,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
commands: commands(),
|
||||||
|
bindings: globalCommands.map((command) => command.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
bindings: globalCommands.flatMap((command) => config.keybinds.get(command.name)),
|
|
||||||
}))
|
|
||||||
|
|
||||||
useBindings(() => ({
|
|
||||||
enabled: () => renderer.currentFocusedEditor === null,
|
enabled: () => renderer.currentFocusedEditor === null,
|
||||||
bindings: baseAndUnfocusedCommands.flatMap((command) => config.keybinds.get(command.name)),
|
bindings: baseAndUnfocusedCommands.map((command) => command.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
|
||||||
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].flatMap((command) => config.keybinds.get(command.name)),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// snap to bottom when session changes
|
// snap to bottom when session changes
|
||||||
@@ -1040,7 +1045,7 @@ function SessionRowView(props: {
|
|||||||
|
|
||||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2 } = useTheme()
|
||||||
const shortcut = useCommandShortcut("session.background")
|
const shortcut = Keymap.useShortcut("session.background")
|
||||||
const visible = createMemo(() => {
|
const visible = createMemo(() => {
|
||||||
const current = props.messages.findLast(
|
const current = props.messages.findLast(
|
||||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||||
@@ -1508,7 +1513,7 @@ function RevertMessage(props: {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const redoKey = useCommandShortcut("session.redo")
|
const redoKey = Keymap.useShortcut("session.redo")
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
onMouseOver={() => setHover(true)}
|
onMouseOver={() => setHover(true)}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useDialog, type DialogContext } from "./dialog"
|
|||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { formatKeyBindings, useKeymapSelector } from "../keymap"
|
|
||||||
|
|
||||||
export interface DialogSelectProps<T> {
|
export interface DialogSelectProps<T> {
|
||||||
title: string
|
title: string
|
||||||
@@ -126,18 +125,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
|
|
||||||
const actions = createMemo(() => props.actions ?? [])
|
const actions = createMemo(() => props.actions ?? [])
|
||||||
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
|
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
|
||||||
const actionBindings = useKeymapSelector((keymap) =>
|
const shortcuts = Keymap.useShortcuts()
|
||||||
keymap.getCommandBindings({
|
|
||||||
visibility: "registered",
|
|
||||||
commands: shownActions().map((item) => item.command),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const actionLabels = createMemo(() => {
|
const actionLabels = createMemo(() => {
|
||||||
const labels = new Map<string, string>()
|
const labels = new Map<string, string>()
|
||||||
|
|
||||||
for (const action of shownActions()) {
|
for (const action of shownActions()) {
|
||||||
const label = formatKeyBindings(actionBindings().get(action.command), config)
|
const label = shortcuts.all(action.command)
|
||||||
if (label) labels.set(action.command, label)
|
if (label) labels.set(action.command, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +1,71 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { testRender } from "@opentui/solid"
|
||||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
|
||||||
import { type TextareaRenderable } from "@opentui/core"
|
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { onCleanup, onMount } from "solid-js"
|
import { ConfigProvider } from "../src/config"
|
||||||
import { TuiKeybind } from "../src/config/keybind"
|
import { Keymap } from "../src/context/keymap"
|
||||||
import {
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
formatKeySequence,
|
|
||||||
getOpencodeModeStack,
|
|
||||||
OPENCODE_BASE_MODE,
|
|
||||||
OpencodeKeymapProvider,
|
|
||||||
registerOpencodeKeymap,
|
|
||||||
} from "../src/keymap"
|
|
||||||
|
|
||||||
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
|
||||||
const keybinds = TuiKeybind.parse(input)
|
|
||||||
return {
|
|
||||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
|
||||||
commandMap: TuiKeybind.CommandMap,
|
|
||||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
|
||||||
}),
|
|
||||||
leader_timeout: 2000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("legacy page key aliases compile as page keys", async () => {
|
test("legacy page key aliases compile as page keys", async () => {
|
||||||
const sequences: Record<string, string[][]> = {}
|
let read = () => ({ up: "", down: "" })
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
Keymap.createLayer(() => ({
|
||||||
const config = createResolvedKeymapConfig({
|
commands: [
|
||||||
messages_page_up: "pgup",
|
{ id: "session.page.up", run() {} },
|
||||||
messages_page_down: "pgdown",
|
{ id: "session.page.down", run() {} },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
read = () => ({
|
||||||
|
up: shortcuts.get("session.page.up") ?? "",
|
||||||
|
down: shortcuts.get("session.page.down") ?? "",
|
||||||
})
|
})
|
||||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
return <box />
|
||||||
const offLayer = keymap.registerLayer({
|
|
||||||
bindings: ["session.page.up", "session.page.down"].flatMap((command) => config.keybinds.get(command)),
|
|
||||||
})
|
|
||||||
const bindings = keymap.getCommandBindings({
|
|
||||||
visibility: "registered",
|
|
||||||
commands: ["session.page.up", "session.page.down"],
|
|
||||||
})
|
|
||||||
sequences.up =
|
|
||||||
bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
|
||||||
sequences.down =
|
|
||||||
bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
|
||||||
onCleanup(() => {
|
|
||||||
offLayer()
|
|
||||||
offKeymap()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<box />
|
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />)
|
const app = await testRender(() => (
|
||||||
|
<ConfigProvider
|
||||||
|
config={createTuiResolvedConfig({
|
||||||
|
keybinds: {
|
||||||
|
messages_page_up: "pgup",
|
||||||
|
messages_page_down: "pgdown",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<Harness />
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
))
|
||||||
try {
|
try {
|
||||||
expect(sequences).toEqual({
|
expect(read()).toEqual({ up: "pgup", down: "pgdn" })
|
||||||
up: [["pageup"]],
|
|
||||||
down: [["pagedown"]],
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats navigation keys as arrows", async () => {
|
test("formats navigation keys as arrows", async () => {
|
||||||
const shortcuts: Record<string, string> = {}
|
let read = () => ({}) as Record<string, string>
|
||||||
|
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
Keymap.createLayer(() => ({
|
||||||
const config = createResolvedKeymapConfig()
|
commands: commands.map((id) => ({ id, run() {} })),
|
||||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
}))
|
||||||
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
|
read = () => Object.fromEntries(commands.map((id) => [id, shortcuts.get(id) ?? ""]))
|
||||||
const offLayer = keymap.registerLayer({
|
return <box />
|
||||||
bindings: commands.flatMap((command) => config.keybinds.get(command)),
|
|
||||||
})
|
|
||||||
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
|
|
||||||
commands.forEach((command) => {
|
|
||||||
shortcuts[command] = formatKeySequence(bindings.get(command)?.[0]?.sequence, config)
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
offLayer()
|
|
||||||
offKeymap()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<box />
|
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />)
|
const app = await testRender(() => (
|
||||||
|
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<Harness />
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
))
|
||||||
try {
|
try {
|
||||||
expect(shortcuts).toEqual({
|
expect(read()).toEqual({
|
||||||
"session.parent": "↑",
|
"session.parent": "↑",
|
||||||
"session.child.first": "↓",
|
"session.child.first": "↓",
|
||||||
"session.child.previous": "←",
|
"session.child.previous": "←",
|
||||||
@@ -111,133 +76,41 @@ test("formats navigation keys as arrows", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dispatches message navigation while the composer is focused", async () => {
|
test("global commands stay reachable when the mode changes", async () => {
|
||||||
for (const kittyKeyboard of [false, true]) {
|
const calls: string[] = []
|
||||||
const counts = {
|
let exercise = () => {}
|
||||||
"session.first": 0,
|
|
||||||
"session.message.previous": 0,
|
|
||||||
"session.message.next": 0,
|
|
||||||
"session.messages_last_user": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
function Harness() {
|
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
const config = createResolvedKeymapConfig()
|
|
||||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
|
||||||
const commands = Object.keys(counts) as (keyof typeof counts)[]
|
|
||||||
const offLayer = keymap.registerLayer({
|
|
||||||
commands: commands.map((name) => ({
|
|
||||||
name,
|
|
||||||
run() {
|
|
||||||
counts[name]++
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
bindings: commands.flatMap((command) => config.keybinds.get(command)),
|
|
||||||
})
|
|
||||||
let textarea: TextareaRenderable
|
|
||||||
onMount(() => textarea.focus())
|
|
||||||
onCleanup(() => {
|
|
||||||
offLayer()
|
|
||||||
offKeymap()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<textarea ref={(value) => (textarea = value)} />
|
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />, { kittyKeyboard })
|
|
||||||
try {
|
|
||||||
await app.renderOnce()
|
|
||||||
app.mockInput.pressArrow("up", { meta: true })
|
|
||||||
app.mockInput.pressArrow("down", { meta: true })
|
|
||||||
app.mockInput.pressKey("HOME", { meta: true })
|
|
||||||
app.mockInput.pressKey("END", { meta: true })
|
|
||||||
expect(counts).toEqual({
|
|
||||||
"session.first": 1,
|
|
||||||
"session.message.previous": 1,
|
|
||||||
"session.message.next": 1,
|
|
||||||
"session.messages_last_user": 1,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("mode-less bindings stay active when opencode mode changes", async () => {
|
|
||||||
const counts: Record<string, Record<string, number>> = {}
|
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
const keymap = Keymap.use()
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
Keymap.createLayer(() => ({
|
||||||
const config = createResolvedKeymapConfig()
|
mode: "global",
|
||||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
commands: [{ id: "session.list", run: () => void calls.push("global") }],
|
||||||
const offGlobal = keymap.registerLayer({
|
}))
|
||||||
commands: [
|
Keymap.createLayer(() => ({
|
||||||
{ name: "session.list", run() {} },
|
commands: [{ id: "model.list", run: () => void calls.push("base") }],
|
||||||
{ name: "session.new", run() {} },
|
}))
|
||||||
{ name: "session.page.up", run() {} },
|
|
||||||
{ name: "session.first", run() {} },
|
|
||||||
],
|
|
||||||
bindings: ["session.list", "session.new", "session.page.up", "session.first"].flatMap((command) =>
|
|
||||||
config.keybinds.get(command),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
const offBase = keymap.registerLayer({
|
|
||||||
mode: OPENCODE_BASE_MODE,
|
|
||||||
commands: [{ name: "model.list", run() {} }],
|
|
||||||
bindings: config.keybinds.get("model.list"),
|
|
||||||
})
|
|
||||||
const activeCounts = () =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Array.from(
|
|
||||||
keymap.getCommandBindings({
|
|
||||||
visibility: "active",
|
|
||||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
|
||||||
}),
|
|
||||||
([command, bindings]) => [command, bindings.length],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
counts.base = activeCounts()
|
exercise = () => {
|
||||||
const popQuestion = getOpencodeModeStack(keymap).push("question")
|
keymap.dispatch("session.list")
|
||||||
counts.question = activeCounts()
|
keymap.dispatch("model.list")
|
||||||
popQuestion()
|
const pop = keymap.mode.push("question")
|
||||||
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
|
keymap.dispatch("session.list")
|
||||||
counts.autocomplete = activeCounts()
|
keymap.dispatch("model.list")
|
||||||
popAutocomplete()
|
pop()
|
||||||
|
}
|
||||||
onCleanup(() => {
|
return <box />
|
||||||
offBase()
|
|
||||||
offGlobal()
|
|
||||||
offKeymap()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<box />
|
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />)
|
const app = await testRender(() => (
|
||||||
|
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<Harness />
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
))
|
||||||
try {
|
try {
|
||||||
expect(counts).toEqual({
|
exercise()
|
||||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 1 },
|
expect(calls).toEqual(["global", "base", "global"])
|
||||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 0 },
|
|
||||||
autocomplete: {
|
|
||||||
"session.list": 1,
|
|
||||||
"session.new": 1,
|
|
||||||
"session.page.up": 2,
|
|
||||||
"session.first": 3,
|
|
||||||
"model.list": 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user