internal which-key plugin, inactive by default (#26337)
This commit is contained in:
@@ -36,6 +36,7 @@ Example:
|
|||||||
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
||||||
- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
|
- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
|
||||||
- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
|
- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
|
||||||
|
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
|
||||||
- `plugin_enabled` is merged across config layers.
|
- `plugin_enabled` is merged across config layers.
|
||||||
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
|||||||
- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command.
|
- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command.
|
||||||
- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution.
|
- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution.
|
||||||
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
|
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
|
||||||
|
- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options.
|
||||||
|
|
||||||
### Keys
|
### Keys
|
||||||
|
|
||||||
@@ -314,6 +316,7 @@ Theme install behavior:
|
|||||||
Current host slot names:
|
Current host slot names:
|
||||||
|
|
||||||
- `app`
|
- `app`
|
||||||
|
- `app_bottom`
|
||||||
- `home_logo`
|
- `home_logo`
|
||||||
- `home_prompt` with props `{ workspace_id?, ref? }`
|
- `home_prompt` with props `{ workspace_id?, ref? }`
|
||||||
- `home_prompt_right` with props `{ workspace_id? }`
|
- `home_prompt_right` with props `{ workspace_id? }`
|
||||||
@@ -332,7 +335,8 @@ Slot notes:
|
|||||||
- `api.slots.register(plugin)` does not return an unregister function.
|
- `api.slots.register(plugin)` does not return an unregister function.
|
||||||
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||||
- Plugin-provided `id` is not allowed.
|
- Plugin-provided `id` is not allowed.
|
||||||
- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `app_bottom`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||||
|
- `app_bottom` is rendered in normal layout flow below the active route, while `app` is rendered afterward for global app-level UI.
|
||||||
- Plugins can define custom slot names in `api.slots.register(...)` and render them from plugin UI with `ui.Slot`.
|
- Plugins can define custom slot names in `api.slots.register(...)` and render them from plugin UI with `ui.Slot`.
|
||||||
|
|
||||||
### Plugin control and lifecycle
|
### Plugin control and lifecycle
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
{
|
{
|
||||||
name: "command.palette.show",
|
name: "command.palette.show",
|
||||||
title: "Show command palette",
|
title: "Show command palette",
|
||||||
|
category: "System",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
command.show()
|
command.show()
|
||||||
@@ -852,6 +853,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
<box
|
<box
|
||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
height={dimensions().height}
|
height={dimensions().height}
|
||||||
|
flexDirection="column"
|
||||||
backgroundColor={theme.background}
|
backgroundColor={theme.background}
|
||||||
onMouseDown={(evt) => {
|
onMouseDown={(evt) => {
|
||||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||||
@@ -867,6 +869,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
<TimeToFirstDraw />
|
<TimeToFirstDraw />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={ready()}>
|
<Show when={ready()}>
|
||||||
|
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={route.data.type === "home"}>
|
<Match when={route.data.type === "home"}>
|
||||||
<Home />
|
<Home />
|
||||||
@@ -875,9 +878,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
<Session />
|
<Session />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Show>
|
|
||||||
{plugin()}
|
{plugin()}
|
||||||
|
</box>
|
||||||
|
<box flexShrink={0}>
|
||||||
|
<TuiPluginRuntime.Slot name="app_bottom" />
|
||||||
|
</box>
|
||||||
<TuiPluginRuntime.Slot name="app" />
|
<TuiPluginRuntime.Slot name="app" />
|
||||||
|
</Show>
|
||||||
<StartupLoading ready={ready} />
|
<StartupLoading ready={ready} />
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ function AutoMethod(props: AutoMethodProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "c",
|
key: "c",
|
||||||
|
desc: "Copy provider code",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const code =
|
const code =
|
||||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||||
|
|||||||
@@ -48,18 +48,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "left",
|
key: "left",
|
||||||
|
desc: "Previous retry option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "right",
|
key: "right",
|
||||||
|
desc: "Next retry option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tab",
|
key: "tab",
|
||||||
|
desc: "Next retry option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
|
desc: "Confirm retry option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (selected() === "action") runAction(props, dialog)
|
if (selected() === "action") runAction(props, dialog)
|
||||||
else dismiss(props, dialog)
|
else dismiss(props, dialog)
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ export function DialogSessionDeleteFailed(props: {
|
|||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "return", cmd: () => void confirm() },
|
{ key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() },
|
||||||
{ key: "left", cmd: () => setStore("active", "delete") },
|
{ key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
|
||||||
{ key: "up", cmd: () => setStore("active", "delete") },
|
{ key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
|
||||||
{ key: "right", cmd: () => setStore("active", "restore") },
|
{ key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||||
{ key: "down", cmd: () => setStore("active", "restore") },
|
{ key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
|
|||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "return", cmd: () => void confirm() },
|
{ key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
|
||||||
{ key: "left", cmd: () => setStore("active", "cancel") },
|
{ key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
|
||||||
{ key: "right", cmd: () => setStore("active", "restore") },
|
{ key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -528,6 +528,8 @@ export function Autocomplete(props: {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.prev",
|
name: "prompt.autocomplete.prev",
|
||||||
|
title: "Previous autocomplete item",
|
||||||
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(-1)
|
move(-1)
|
||||||
@@ -535,6 +537,8 @@ export function Autocomplete(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.next",
|
name: "prompt.autocomplete.next",
|
||||||
|
title: "Next autocomplete item",
|
||||||
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(1)
|
move(1)
|
||||||
@@ -542,18 +546,24 @@ export function Autocomplete(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.hide",
|
name: "prompt.autocomplete.hide",
|
||||||
|
title: "Hide autocomplete",
|
||||||
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
hide()
|
hide()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.select",
|
name: "prompt.autocomplete.select",
|
||||||
|
title: "Select autocomplete item",
|
||||||
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
select()
|
select()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "prompt.autocomplete.complete",
|
name: "prompt.autocomplete.complete",
|
||||||
|
title: "Complete autocomplete item",
|
||||||
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
const selected = options()[store.selected]
|
const selected = options()[store.selected]
|
||||||
if (selected?.isDirectory) {
|
if (selected?.isDirectory) {
|
||||||
|
|||||||
@@ -892,6 +892,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "!",
|
key: "!",
|
||||||
|
desc: "Shell mode",
|
||||||
|
group: "Prompt",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
setStore("placeholder", randomIndex(shell().length))
|
setStore("placeholder", randomIndex(shell().length))
|
||||||
setStore("mode", "shell")
|
setStore("mode", "shell")
|
||||||
@@ -905,7 +907,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||||
bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
|
bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -916,7 +918,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
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", cmd: () => setStore("mode", "normal") }],
|
bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -936,6 +938,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.history.previous",
|
name: "prompt.history.previous",
|
||||||
|
title: "Previous prompt history",
|
||||||
|
category: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (input.cursorOffset !== 0) {
|
if (input.cursorOffset !== 0) {
|
||||||
input.cursorOffset = 0
|
input.cursorOffset = 0
|
||||||
@@ -972,6 +976,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "prompt.history.next",
|
name: "prompt.history.next",
|
||||||
|
title: "Next prompt history",
|
||||||
|
category: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (input.cursorOffset !== input.plainText.length) {
|
if (input.cursorOffset !== input.plainText.length) {
|
||||||
input.cursorOffset = input.plainText.length
|
input.cursorOffset = input.plainText.length
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ const GlobalKeymapSection = {
|
|||||||
"terminal.title.toggle": keymapBinding("none"),
|
"terminal.title.toggle": keymapBinding("none"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WhichKeyKeymapSection = {
|
||||||
|
"tui-which-key.toggle": keymapBinding("ctrl+alt+k"),
|
||||||
|
"tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"),
|
||||||
|
"tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"),
|
||||||
|
"tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["),
|
||||||
|
"tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"),
|
||||||
|
"tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"),
|
||||||
|
"tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"),
|
||||||
|
"tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"),
|
||||||
|
"tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"),
|
||||||
|
"tui-which-key.home": keymapBinding("ctrl+alt+home"),
|
||||||
|
"tui-which-key.end": keymapBinding("ctrl+alt+end"),
|
||||||
|
}
|
||||||
|
|
||||||
const SessionKeymapSection = {
|
const SessionKeymapSection = {
|
||||||
"session.share": keymapBinding("none"),
|
"session.share": keymapBinding("none"),
|
||||||
"session.rename": keymapBinding("ctrl+r"),
|
"session.rename": keymapBinding("ctrl+r"),
|
||||||
@@ -231,6 +245,7 @@ const HomeTipsKeymapSection = {
|
|||||||
|
|
||||||
const KeymapSectionsShape = {
|
const KeymapSectionsShape = {
|
||||||
global: keymapSection(GlobalKeymapSection),
|
global: keymapSection(GlobalKeymapSection),
|
||||||
|
which_key: keymapSection(WhichKeyKeymapSection),
|
||||||
session: keymapSection(SessionKeymapSection),
|
session: keymapSection(SessionKeymapSection),
|
||||||
prompt: keymapSection(PromptKeymapSection),
|
prompt: keymapSection(PromptKeymapSection),
|
||||||
autocomplete: keymapSection(AutocompleteKeymapSection),
|
autocomplete: keymapSection(AutocompleteKeymapSection),
|
||||||
@@ -246,6 +261,7 @@ const KeymapSectionsShape = {
|
|||||||
|
|
||||||
const KeymapSectionsInputShape = {
|
const KeymapSectionsInputShape = {
|
||||||
global: keymapSectionInput(GlobalKeymapSection).optional(),
|
global: keymapSectionInput(GlobalKeymapSection).optional(),
|
||||||
|
which_key: keymapSectionInput(WhichKeyKeymapSection).optional(),
|
||||||
session: keymapSectionInput(SessionKeymapSection).optional(),
|
session: keymapSectionInput(SessionKeymapSection).optional(),
|
||||||
prompt: keymapSectionInput(PromptKeymapSection).optional(),
|
prompt: keymapSectionInput(PromptKeymapSection).optional(),
|
||||||
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
|
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
|
||||||
@@ -271,6 +287,7 @@ export type KeymapInfo = {
|
|||||||
|
|
||||||
export const KeymapSectionGroups = {
|
export const KeymapSectionGroups = {
|
||||||
global: "Global",
|
global: "Global",
|
||||||
|
which_key: "System",
|
||||||
session: "Session",
|
session: "Session",
|
||||||
prompt: "Prompt",
|
prompt: "Prompt",
|
||||||
autocomplete: "Autocomplete",
|
autocomplete: "Autocomplete",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { Tips } from "./tips-view"
|
import { Tips } from "./tips-view"
|
||||||
import { useBindings } from "../../keymap"
|
import { useBindings } from "../../keymap"
|
||||||
@@ -50,7 +51,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-context"
|
const id = "internal:sidebar-context"
|
||||||
@@ -55,7 +56,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-files"
|
const id = "internal:sidebar-files"
|
||||||
@@ -54,7 +55,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-lsp"
|
const id = "internal:sidebar-lsp"
|
||||||
@@ -58,7 +59,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-mcp"
|
const id = "internal:sidebar-mcp"
|
||||||
@@ -88,7 +89,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||||
import { TodoItem } from "../../component/todo-item"
|
import { TodoItem } from "../../component/todo-item"
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||||
@@ -40,7 +41,7 @@ function Install(props: { api: TuiPluginApi }) {
|
|||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
enabled: !busy(),
|
enabled: !busy(),
|
||||||
bindings: [{ key: "tab", cmd: () => setGlobal((value) => !value) }],
|
bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,7 +262,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
import { useSyncV2 } from "@tui/context/sync-v2"
|
import { useSyncV2 } from "@tui/context/sync-v2"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
@@ -59,6 +60,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "escape",
|
key: "escape",
|
||||||
|
desc: "Back to session",
|
||||||
|
group: "Session",
|
||||||
cmd() {
|
cmd() {
|
||||||
props.api.route.navigate("session", { sessionID: props.sessionID })
|
props.api.route.navigate("session", { sessionID: props.sessionID })
|
||||||
},
|
},
|
||||||
@@ -1144,7 +1147,7 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: TuiPluginModule & { id: string } = {
|
const plugin: InternalTuiPlugin = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||||
|
import { useBindings, useKeymapSelector } from "../../keymap"
|
||||||
|
import type { ActiveKey } from "@opentui/keymap"
|
||||||
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
|
|
||||||
|
const command = {
|
||||||
|
toggle: "tui-which-key.toggle",
|
||||||
|
toggleLayout: "tui-which-key.layout.toggle",
|
||||||
|
togglePending: "tui-which-key.pending.toggle",
|
||||||
|
groupPrevious: "tui-which-key.group.previous",
|
||||||
|
groupNext: "tui-which-key.group.next",
|
||||||
|
scrollUp: "tui-which-key.scroll.up",
|
||||||
|
scrollDown: "tui-which-key.scroll.down",
|
||||||
|
pageUp: "tui-which-key.page.up",
|
||||||
|
pageDown: "tui-which-key.page.down",
|
||||||
|
home: "tui-which-key.home",
|
||||||
|
end: "tui-which-key.end",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const LAYER_PRIORITY = 900
|
||||||
|
const KV_LAYOUT = "which_key_layout"
|
||||||
|
const KV_PENDING_PREVIEW = "which_key_pending_preview"
|
||||||
|
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
|
||||||
|
const scrollCommands = [
|
||||||
|
command.scrollUp,
|
||||||
|
command.scrollDown,
|
||||||
|
command.pageUp,
|
||||||
|
command.pageDown,
|
||||||
|
command.home,
|
||||||
|
command.end,
|
||||||
|
] as const
|
||||||
|
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
|
||||||
|
const COLUMN_GAP = 4
|
||||||
|
const TAB_GAP = 3
|
||||||
|
const MIN_TAB_GAP = 1
|
||||||
|
const TAB_CONTENT_GAP = 1
|
||||||
|
const MIN_COLUMN_WIDTH = 28
|
||||||
|
const MAX_COLUMN_WIDTH = 44
|
||||||
|
const PANEL_HEIGHT_RATIO = 0.3
|
||||||
|
const MIN_PANEL_HEIGHT = 8
|
||||||
|
const MAX_PANEL_HEIGHT = 16
|
||||||
|
const PANEL_TOP_PADDING = 1
|
||||||
|
const FOOTER_HEIGHT = 1
|
||||||
|
const FOOTER_MARGIN = 1
|
||||||
|
const UNKNOWN = "Unknown"
|
||||||
|
|
||||||
|
type Layout = "dock" | "overlay"
|
||||||
|
|
||||||
|
type Color = RGBA | string
|
||||||
|
|
||||||
|
type Skin = {
|
||||||
|
panel: Color
|
||||||
|
text: Color
|
||||||
|
muted: Color
|
||||||
|
subtle: Color
|
||||||
|
key: Color
|
||||||
|
accent: Color
|
||||||
|
tab: Color
|
||||||
|
tabText: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
type: "entry"
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
group: string
|
||||||
|
continues: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
label: string
|
||||||
|
entries: Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
|
||||||
|
|
||||||
|
type GroupHeader = {
|
||||||
|
type: "group"
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item = Entry | GroupHeader
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
if (typeof value !== "string") return undefined
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
|
||||||
|
const value = Reflect.get(api.theme.current, name)
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
if (value instanceof RGBA) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function skin(api: TuiPluginApi): Skin {
|
||||||
|
return {
|
||||||
|
panel: ink(api, "backgroundMenu", "#1c1c1c"),
|
||||||
|
text: ink(api, "text", "#f0f0f0"),
|
||||||
|
muted: ink(api, "textMuted", "#a5a5a5"),
|
||||||
|
subtle: ink(api, "borderSubtle", "#6f6f6f"),
|
||||||
|
key: ink(api, "warning", "#ffd75f"),
|
||||||
|
accent: ink(api, "primary", "#5f87ff"),
|
||||||
|
tab: ink(api, "primary", "#5f87ff"),
|
||||||
|
tabText: ink(api, "selectedListItemText", "#ffffff"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
|
||||||
|
const group = text(active.bindingAttrs?.group)
|
||||||
|
if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN
|
||||||
|
return (
|
||||||
|
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
|
||||||
|
if (active.continues) return "System"
|
||||||
|
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
|
||||||
|
const key = api.keys.formatSequence([
|
||||||
|
{
|
||||||
|
stroke: active.stroke,
|
||||||
|
display: active.display,
|
||||||
|
tokenName: active.tokenName,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const label = activeKeyLabel(active)
|
||||||
|
return {
|
||||||
|
type: "entry",
|
||||||
|
key,
|
||||||
|
label: active.continues ? `+${label}` : label,
|
||||||
|
group: activeKeyGroup(active),
|
||||||
|
continues: active.continues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function grouped(entries: Entry[]): Group[] {
|
||||||
|
const map = new Map<string, Entry[]>()
|
||||||
|
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
|
||||||
|
return [...map]
|
||||||
|
.map(([label, entries]) => ({
|
||||||
|
label,
|
||||||
|
entries: entries.toSorted(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.toSorted((a, b) => a.label.localeCompare(b.label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandShortcut(api: TuiPluginApi, name: string) {
|
||||||
|
return useKeymapSelector((keymap) =>
|
||||||
|
api.keys.formatSequence(
|
||||||
|
keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout(value: unknown): Layout {
|
||||||
|
if (value === "overlay") return "overlay"
|
||||||
|
return "dock"
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeHint(props: { api: TuiPluginApi }) {
|
||||||
|
const trigger = commandShortcut(props.api, command.toggle)
|
||||||
|
const look = createMemo(() => skin(props.api))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
|
||||||
|
<text fg={look().muted} wrapMode="none">
|
||||||
|
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WhichKeyPanel(props: {
|
||||||
|
api: TuiPluginApi
|
||||||
|
layout: Layout
|
||||||
|
mode: () => Layout
|
||||||
|
pendingPreview: () => boolean
|
||||||
|
pinned: () => boolean
|
||||||
|
}) {
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const [offset, setOffset] = createSignal(0)
|
||||||
|
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
|
||||||
|
const pending = useKeymapSelector((keymap) => keymap.getPendingSequence())
|
||||||
|
const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
|
||||||
|
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
|
||||||
|
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
|
||||||
|
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
|
||||||
|
const pendingMode = createMemo(() => visible() && pendingActive())
|
||||||
|
const left = 0
|
||||||
|
const width = createMemo(() => Math.max(1, dimensions().width))
|
||||||
|
const panelHeight = createMemo(() =>
|
||||||
|
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
|
||||||
|
)
|
||||||
|
const contentWidth = createMemo(() => Math.max(1, width() - 2))
|
||||||
|
const columns = createMemo(() =>
|
||||||
|
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
|
||||||
|
)
|
||||||
|
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
|
||||||
|
const groups = createMemo(() => grouped(entries()))
|
||||||
|
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
|
||||||
|
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
|
||||||
|
const footerVisible = createMemo(() => !pendingMode())
|
||||||
|
const rows = createMemo(() =>
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
panelHeight() -
|
||||||
|
PANEL_TOP_PADDING -
|
||||||
|
(headerVisible() ? 1 : 0) -
|
||||||
|
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
|
||||||
|
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const pageSize = createMemo(() => rows() * columns())
|
||||||
|
const currentGroup = createMemo(() => {
|
||||||
|
const group = activeGroup()
|
||||||
|
return groups().find((item) => item.label === group) ?? groups()[0]
|
||||||
|
})
|
||||||
|
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
|
||||||
|
const items = createMemo<Item[]>(() => {
|
||||||
|
if (!pendingMode()) return activeEntries()
|
||||||
|
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
|
||||||
|
})
|
||||||
|
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
|
||||||
|
const shown = createMemo(() => {
|
||||||
|
const columnsItems: Item[][] = []
|
||||||
|
let index = offset()
|
||||||
|
for (let column = 0; column < columns() && index < items().length; column++) {
|
||||||
|
const list: Item[] = []
|
||||||
|
while (list.length < rows() && index < items().length) {
|
||||||
|
list.push(items()[index]!)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
columnsItems.push(list)
|
||||||
|
}
|
||||||
|
return columnsItems
|
||||||
|
})
|
||||||
|
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
|
||||||
|
const trigger = commandShortcut(props.api, command.toggle)
|
||||||
|
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
|
||||||
|
const upActive = createMemo(() => offset() > 0)
|
||||||
|
const downActive = createMemo(() => offset() < maxOffset())
|
||||||
|
const scrollable = createMemo(() => maxOffset() > 0)
|
||||||
|
const headerItems = createMemo<HeaderItem[]>(() => [
|
||||||
|
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
|
||||||
|
...(scrollable() ? [{ type: "scroll" as const }] : []),
|
||||||
|
])
|
||||||
|
const tabGap = createMemo(() => {
|
||||||
|
const itemCount = headerItems().length
|
||||||
|
if (itemCount <= 1) return 0
|
||||||
|
const itemWidth = headerItems().reduce(
|
||||||
|
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return Math.max(
|
||||||
|
MIN_TAB_GAP,
|
||||||
|
Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
|
||||||
|
const look = createMemo(() => skin(props.api))
|
||||||
|
const columnWidth = createMemo(() =>
|
||||||
|
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
|
||||||
|
)
|
||||||
|
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
|
||||||
|
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
|
||||||
|
const moveGroup = (delta: number) => {
|
||||||
|
if (pendingMode()) return
|
||||||
|
const list = groups()
|
||||||
|
if (!list.length) return
|
||||||
|
const index = Math.max(
|
||||||
|
0,
|
||||||
|
list.findIndex((item) => item.label === currentGroup()?.label),
|
||||||
|
)
|
||||||
|
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
|
||||||
|
setOffset(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
useBindings(() => ({
|
||||||
|
priority: 1000,
|
||||||
|
enabled: visible(),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: command.groupPrevious,
|
||||||
|
title: "Previous key binding group",
|
||||||
|
desc: "Show the previous which-key group",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
moveGroup(-1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.groupNext,
|
||||||
|
title: "Next key binding group",
|
||||||
|
desc: "Show the next which-key group",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
moveGroup(1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.scrollUp,
|
||||||
|
title: "Scroll key bindings up",
|
||||||
|
desc: "Scroll the which-key panel up",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
scroll(-columns())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.scrollDown,
|
||||||
|
title: "Scroll key bindings down",
|
||||||
|
desc: "Scroll the which-key panel down",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
scroll(columns())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.pageUp,
|
||||||
|
title: "Page key bindings up",
|
||||||
|
desc: "Page the which-key panel up",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
scroll(-pageSize())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.pageDown,
|
||||||
|
title: "Page key bindings down",
|
||||||
|
desc: "Page the which-key panel down",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
scroll(pageSize())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.home,
|
||||||
|
title: "First key binding",
|
||||||
|
desc: "Jump to the first which-key binding",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
setOffset(0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.end,
|
||||||
|
title: "Last key binding",
|
||||||
|
desc: "Jump to the last which-key binding",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
setOffset(maxOffset())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands),
|
||||||
|
}))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (pendingMode()) return
|
||||||
|
const group = currentGroup()
|
||||||
|
if (group?.label === activeGroup()) return
|
||||||
|
setActiveGroup(group?.label)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (pendingMode()) return
|
||||||
|
activeGroup()
|
||||||
|
setOffset(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!visible()) setOffset(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
pending()
|
||||||
|
setOffset(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setOffset((value) => clamp(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={visible()}>
|
||||||
|
<box
|
||||||
|
position={props.layout === "overlay" ? "absolute" : "relative"}
|
||||||
|
zIndex={3500}
|
||||||
|
left={left}
|
||||||
|
bottom={props.layout === "overlay" ? 0 : undefined}
|
||||||
|
width={dimensions().width}
|
||||||
|
height={panelHeight()}
|
||||||
|
backgroundColor={look().panel}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
paddingTop={1}
|
||||||
|
flexShrink={0}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<Show when={headerVisible()}>
|
||||||
|
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
|
||||||
|
<For each={headerItems()}>
|
||||||
|
{(item) => (
|
||||||
|
<Show
|
||||||
|
when={item.type === "tab" ? item.group : undefined}
|
||||||
|
fallback={
|
||||||
|
<box flexShrink={0}>
|
||||||
|
<text wrapMode="none">
|
||||||
|
<span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
|
||||||
|
<span style={{ fg: look().muted }}> </span>
|
||||||
|
<span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(group) => {
|
||||||
|
const selected = createMemo(() => currentGroup()?.label === group().label)
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
flexShrink={0}
|
||||||
|
backgroundColor={selected() ? look().tab : undefined}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setActiveGroup(group().label)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={selected() ? look().tabText : look().muted}
|
||||||
|
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||||
|
wrapMode="none"
|
||||||
|
>
|
||||||
|
{group().label}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={tabsVisible()}>
|
||||||
|
<box height={TAB_CONTENT_GAP} flexShrink={0} />
|
||||||
|
</Show>
|
||||||
|
<box height={rows()} flexShrink={0} flexDirection="column">
|
||||||
|
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
|
||||||
|
<For each={rowIndexes()}>
|
||||||
|
{(row) => (
|
||||||
|
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
|
||||||
|
<For each={shown()}>
|
||||||
|
{(column) => {
|
||||||
|
const item = createMemo(() => column[row])
|
||||||
|
const entry = createMemo(() => {
|
||||||
|
const value = item()
|
||||||
|
if (value?.type !== "entry") return undefined
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
|
||||||
|
<Show when={item()}>
|
||||||
|
{(value) => (
|
||||||
|
<Show
|
||||||
|
when={entry()}
|
||||||
|
fallback={
|
||||||
|
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||||
|
{value().label}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(binding) => (
|
||||||
|
<>
|
||||||
|
<box flexGrow={1} minWidth={0}>
|
||||||
|
<text
|
||||||
|
fg={binding().continues ? look().accent : look().muted}
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
{binding().label}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexShrink={0}>
|
||||||
|
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||||
|
{binding().key}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={footerVisible()}>
|
||||||
|
<box height={FOOTER_MARGIN} flexShrink={0} />
|
||||||
|
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||||
|
<box>
|
||||||
|
<text fg={look().text} wrapMode="none">
|
||||||
|
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box>
|
||||||
|
<text fg={look().text} wrapMode="none">
|
||||||
|
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui: TuiPlugin = async (api) => {
|
||||||
|
const [pinned, setPinned] = createSignal(false)
|
||||||
|
const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
|
||||||
|
const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
|
||||||
|
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
priority: LAYER_PRIORITY,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: command.toggle,
|
||||||
|
title: "Show key bindings",
|
||||||
|
desc: "Toggle which-key overlay",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
setPinned((value) => !value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.toggleLayout,
|
||||||
|
title: "Toggle key bindings layout",
|
||||||
|
desc: "Switch which-key between dock and overlay mode",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
setMode((value) => {
|
||||||
|
const next = value === "dock" ? "overlay" : "dock"
|
||||||
|
api.kv.set(KV_LAYOUT, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: command.togglePending,
|
||||||
|
title: "Toggle pending key preview",
|
||||||
|
desc: "Automatically show which-key for pending key sequences in overlay mode",
|
||||||
|
category: "System",
|
||||||
|
run() {
|
||||||
|
setPendingPreview((value) => {
|
||||||
|
api.kv.set(KV_PENDING_PREVIEW, !value)
|
||||||
|
return !value
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands),
|
||||||
|
})
|
||||||
|
|
||||||
|
api.slots.register({
|
||||||
|
order: 200,
|
||||||
|
slots: {
|
||||||
|
home_bottom() {
|
||||||
|
return <HomeHint api={api} />
|
||||||
|
},
|
||||||
|
app() {
|
||||||
|
return (
|
||||||
|
<Show when={mode() === "overlay"}>
|
||||||
|
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
app_bottom() {
|
||||||
|
return (
|
||||||
|
<Show when={mode() === "dock"}>
|
||||||
|
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: InternalTuiPlugin = {
|
||||||
|
id: "tui-which-key",
|
||||||
|
enabled: false,
|
||||||
|
tui,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
@@ -8,12 +8,14 @@ import SidebarFiles from "../feature-plugins/sidebar/files"
|
|||||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||||
import PluginManager from "../feature-plugins/system/plugins"
|
import PluginManager from "../feature-plugins/system/plugins"
|
||||||
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
||||||
|
import WhichKey from "../feature-plugins/system/which-key"
|
||||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
|
||||||
export type InternalTuiPlugin = TuiPluginModule & {
|
export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||||
id: string
|
id: string
|
||||||
tui: TuiPlugin
|
tui: TuiPlugin
|
||||||
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||||
@@ -26,5 +28,6 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
|||||||
SidebarFiles,
|
SidebarFiles,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
PluginManager,
|
PluginManager,
|
||||||
|
WhichKey,
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1049,7 +1049,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved }) {
|
|||||||
meta,
|
meta,
|
||||||
themes: {},
|
themes: {},
|
||||||
plugin: entry.module.tui,
|
plugin: entry.module.tui,
|
||||||
enabled: true,
|
enabled: item.enabled ?? true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1039,8 +1039,8 @@ export function Session() {
|
|||||||
tui: tuiConfig,
|
tui: tuiConfig,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||||
<box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
ref={(r) => (scroll = r)}
|
ref={(r) => (scroll = r)}
|
||||||
|
|||||||
@@ -472,15 +472,22 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "permission.reject.cancel",
|
name: "permission.reject.cancel",
|
||||||
|
title: "Cancel permission rejection",
|
||||||
|
category: "Permission",
|
||||||
run() {
|
run() {
|
||||||
props.onCancel()
|
props.onCancel()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "escape", cmd: () => props.onCancel() },
|
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
|
||||||
...keymapConfig.pick("permission", ["permission.reject.cancel"]),
|
...keymapConfig.pick("permission", ["permission.reject.cancel"]),
|
||||||
{ key: "return", cmd: () => props.onConfirm(input.plainText) },
|
{
|
||||||
|
key: "return",
|
||||||
|
desc: "Confirm permission rejection",
|
||||||
|
group: "Permission",
|
||||||
|
cmd: () => props.onConfirm(input.plainText),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -562,6 +569,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "permission.prompt.escape",
|
name: "permission.prompt.escape",
|
||||||
|
title: "Reject permission",
|
||||||
|
category: "Permission",
|
||||||
run() {
|
run() {
|
||||||
if (!props.escapeKey) return
|
if (!props.escapeKey) return
|
||||||
props.onSelect(props.escapeKey)
|
props.onSelect(props.escapeKey)
|
||||||
@@ -569,6 +578,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "permission.prompt.fullscreen",
|
name: "permission.prompt.fullscreen",
|
||||||
|
title: "Toggle permission fullscreen",
|
||||||
|
category: "Permission",
|
||||||
run() {
|
run() {
|
||||||
if (!props.fullscreen) return
|
if (!props.fullscreen) return
|
||||||
setStore("expanded", (v) => !v)
|
setStore("expanded", (v) => !v)
|
||||||
@@ -578,6 +589,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "left",
|
key: "left",
|
||||||
|
desc: "Previous permission option",
|
||||||
|
group: "Permission",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const idx = keys.indexOf(store.selected)
|
const idx = keys.indexOf(store.selected)
|
||||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||||
@@ -586,6 +599,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "h",
|
key: "h",
|
||||||
|
desc: "Previous permission option",
|
||||||
|
group: "Permission",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const idx = keys.indexOf(store.selected)
|
const idx = keys.indexOf(store.selected)
|
||||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||||
@@ -594,6 +609,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "right",
|
key: "right",
|
||||||
|
desc: "Next permission option",
|
||||||
|
group: "Permission",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const idx = keys.indexOf(store.selected)
|
const idx = keys.indexOf(store.selected)
|
||||||
const next = keys[(idx + 1) % keys.length]
|
const next = keys[(idx + 1) % keys.length]
|
||||||
@@ -602,14 +619,30 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "l",
|
key: "l",
|
||||||
|
desc: "Next permission option",
|
||||||
|
group: "Permission",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const idx = keys.indexOf(store.selected)
|
const idx = keys.indexOf(store.selected)
|
||||||
const next = keys[(idx + 1) % keys.length]
|
const next = keys[(idx + 1) % keys.length]
|
||||||
setStore("selected", next)
|
setStore("selected", next)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: "return", cmd: () => props.onSelect(store.selected) },
|
{
|
||||||
...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []),
|
key: "return",
|
||||||
|
desc: "Select permission option",
|
||||||
|
group: "Permission",
|
||||||
|
cmd: () => props.onSelect(store.selected),
|
||||||
|
},
|
||||||
|
...(props.escapeKey
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "escape",
|
||||||
|
desc: "Reject permission",
|
||||||
|
group: "Permission",
|
||||||
|
cmd: () => props.onSelect(props.escapeKey!),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
|
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
|
||||||
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
|
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "question.edit.clear",
|
name: "question.edit.clear",
|
||||||
|
title: "Clear answer edit",
|
||||||
|
category: "Question",
|
||||||
run() {
|
run() {
|
||||||
const text = textarea?.plainText ?? ""
|
const text = textarea?.plainText ?? ""
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -142,6 +144,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "escape",
|
key: "escape",
|
||||||
|
desc: "Cancel answer edit",
|
||||||
|
group: "Question",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
},
|
},
|
||||||
@@ -149,6 +153,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
...keymapConfig.pick("question", ["question.edit.clear"]),
|
...keymapConfig.pick("question", ["question.edit.clear"]),
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
|
desc: "Submit answer edit",
|
||||||
|
group: "Question",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const text = textarea?.plainText?.trim() ?? ""
|
const text = textarea?.plainText?.trim() ?? ""
|
||||||
const prev = store.custom[store.tab]
|
const prev = store.custom[store.tab]
|
||||||
@@ -203,38 +209,68 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "question.reject",
|
name: "question.reject",
|
||||||
|
title: "Reject question",
|
||||||
|
category: "Question",
|
||||||
run() {
|
run() {
|
||||||
reject()
|
reject()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
|
{
|
||||||
{ key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
|
key: "left",
|
||||||
{ key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
desc: "Previous question",
|
||||||
{ key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
group: "Question",
|
||||||
|
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "h",
|
||||||
|
desc: "Previous question",
|
||||||
|
group: "Question",
|
||||||
|
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
||||||
|
},
|
||||||
|
{ key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
||||||
|
{ key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
||||||
{
|
{
|
||||||
key: "tab",
|
key: "tab",
|
||||||
|
desc: "Next question",
|
||||||
|
group: "Question",
|
||||||
cmd: ({ event }: { event: { shift: boolean } }) => {
|
cmd: ({ event }: { event: { shift: boolean } }) => {
|
||||||
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
|
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(confirm()
|
...(confirm()
|
||||||
? [{ key: "return", cmd: () => submit() }, { key: "escape", cmd: () => reject() }, ...sections.question]
|
? [
|
||||||
|
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
|
||||||
|
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
||||||
|
...sections.question,
|
||||||
|
]
|
||||||
: [
|
: [
|
||||||
...Array.from({ length: max }, (_, index) => ({
|
...Array.from({ length: max }, (_, index) => ({
|
||||||
key: String(index + 1),
|
key: String(index + 1),
|
||||||
|
desc: `Select answer ${index + 1}`,
|
||||||
|
group: "Question",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
moveTo(index)
|
moveTo(index)
|
||||||
selectOption()
|
selectOption()
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
{ key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) },
|
{
|
||||||
{ key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) },
|
key: "up",
|
||||||
{ key: "down", cmd: () => moveTo((store.selected + 1) % total) },
|
desc: "Previous answer",
|
||||||
{ key: "j", cmd: () => moveTo((store.selected + 1) % total) },
|
group: "Question",
|
||||||
{ key: "return", cmd: () => selectOption() },
|
cmd: () => moveTo((store.selected - 1 + total) % total),
|
||||||
{ key: "escape", cmd: () => reject() },
|
},
|
||||||
|
{
|
||||||
|
key: "k",
|
||||||
|
desc: "Previous answer",
|
||||||
|
group: "Question",
|
||||||
|
cmd: () => moveTo((store.selected - 1 + total) % total),
|
||||||
|
},
|
||||||
|
{ key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
||||||
|
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
||||||
|
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
|
||||||
|
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
||||||
...sections.question,
|
...sections.question,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function DialogAlert(props: DialogAlertProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
|
desc: "Confirm alert",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
props.onConfirm?.()
|
props.onConfirm?.()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
|
desc: "Confirm dialog selection",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (store.active === "confirm") props.onConfirm?.()
|
if (store.active === "confirm") props.onConfirm?.()
|
||||||
if (store.active === "cancel") props.onCancel?.()
|
if (store.active === "cancel") props.onCancel?.()
|
||||||
@@ -35,12 +37,16 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "left",
|
key: "left",
|
||||||
|
desc: "Previous dialog option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "right",
|
key: "right",
|
||||||
|
desc: "Next dialog option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "tab",
|
key: "tab",
|
||||||
|
desc: "Next export option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
|
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
|
||||||
"filename",
|
"filename",
|
||||||
@@ -58,6 +60,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "space",
|
key: "space",
|
||||||
|
desc: "Toggle export option",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||||
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export function DialogHelp() {
|
|||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "return", cmd: () => dialog.clear() },
|
{ key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() },
|
||||||
{ key: "escape", cmd: () => dialog.clear() },
|
{ key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "dialog.select.prev",
|
name: "dialog.select.prev",
|
||||||
|
title: "Previous item",
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(-1)
|
move(-1)
|
||||||
@@ -244,6 +246,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dialog.select.next",
|
name: "dialog.select.next",
|
||||||
|
title: "Next item",
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(1)
|
move(1)
|
||||||
@@ -251,6 +255,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dialog.select.page_up",
|
name: "dialog.select.page_up",
|
||||||
|
title: "Page up",
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(-10)
|
move(-10)
|
||||||
@@ -258,6 +264,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dialog.select.page_down",
|
name: "dialog.select.page_down",
|
||||||
|
title: "Page down",
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
move(10)
|
move(10)
|
||||||
@@ -265,6 +273,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dialog.select.home",
|
name: "dialog.select.home",
|
||||||
|
title: "First item",
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
moveTo(0)
|
moveTo(0)
|
||||||
@@ -272,6 +282,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dialog.select.end",
|
name: "dialog.select.end",
|
||||||
|
title: "Last item",
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
moveTo(flat().length - 1)
|
moveTo(flat().length - 1)
|
||||||
@@ -279,10 +291,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dialog.select.submit",
|
name: "dialog.select.submit",
|
||||||
|
title: "Select item",
|
||||||
|
category: "Dialog",
|
||||||
run: submit,
|
run: submit,
|
||||||
},
|
},
|
||||||
...enabledActions.map((item) => ({
|
...enabledActions.map((item) => ({
|
||||||
name: item.command,
|
name: item.command,
|
||||||
|
title: item.title,
|
||||||
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
const option = selected()
|
const option = selected()
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ function init() {
|
|||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "escape",
|
key: "escape",
|
||||||
|
desc: "Close dialog",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (renderer.getSelection()) {
|
if (renderer.getSelection()) {
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
@@ -109,6 +111,8 @@ function init() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ctrl+c",
|
key: "ctrl+c",
|
||||||
|
desc: "Close dialog",
|
||||||
|
group: "Dialog",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (renderer.getSelection()) {
|
if (renderer.getSelection()) {
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
|
|||||||
@@ -156,3 +156,45 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
|||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads disabled-by-default internal plugin inactive and activates on demand", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const config = createTuiResolvedConfig()
|
||||||
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
const api = createTuiPluginApi()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TuiPluginRuntime.init({ api, config })
|
||||||
|
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "internal:plugin-manager")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({
|
||||||
|
id: "tui-which-key",
|
||||||
|
source: "internal",
|
||||||
|
spec: "tui-which-key",
|
||||||
|
target: "tui-which-key",
|
||||||
|
enabled: false,
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true)
|
||||||
|
expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({
|
||||||
|
id: "tui-which-key",
|
||||||
|
source: "internal",
|
||||||
|
spec: "tui-which-key",
|
||||||
|
target: "tui-which-key",
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
expect(api.kv.get("plugin_enabled", {})).toEqual({
|
||||||
|
"tui-which-key": true,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await TuiPluginRuntime.dispose()
|
||||||
|
cwd.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ test("resolves semantic keymap sections", async () => {
|
|||||||
keymap: {
|
keymap: {
|
||||||
sections: {
|
sections: {
|
||||||
global: { "command.palette.show": "alt+p" },
|
global: { "command.palette.show": "alt+p" },
|
||||||
|
which_key: { "tui-which-key.toggle": "alt+k" },
|
||||||
prompt: { "prompt.editor": "ctrl+e" },
|
prompt: { "prompt.editor": "ctrl+e" },
|
||||||
autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
|
autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
|
||||||
dialog_actions: { "dialog.action.toggle": "ctrl+t" },
|
dialog_actions: { "dialog.action.toggle": "ctrl+t" },
|
||||||
@@ -427,6 +428,23 @@ test("resolves semantic keymap sections", async () => {
|
|||||||
const config = await getTuiConfig(tmp.path)
|
const config = await getTuiConfig(tmp.path)
|
||||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
||||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<leader>n")
|
expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<leader>n")
|
||||||
|
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe(
|
||||||
|
"alt+k",
|
||||||
|
)
|
||||||
|
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe(
|
||||||
|
"ctrl+alt+shift+k",
|
||||||
|
)
|
||||||
|
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe(
|
||||||
|
"ctrl+alt+shift+p",
|
||||||
|
)
|
||||||
|
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe(
|
||||||
|
"ctrl+alt+right,ctrl+alt+]",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as
|
||||||
|
| { group?: unknown }
|
||||||
|
| undefined)?.group,
|
||||||
|
).toBe("System")
|
||||||
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
||||||
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
|
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
|
||||||
"ctrl+j",
|
"ctrl+j",
|
||||||
@@ -467,6 +485,7 @@ test("legacy keybinds transform into semantic keymap sections", async () => {
|
|||||||
const config = await getTuiConfig(tmp.path)
|
const config = await getTuiConfig(tmp.path)
|
||||||
expect(Object.keys(config.keymap.sections)).toEqual([
|
expect(Object.keys(config.keymap.sections)).toEqual([
|
||||||
"global",
|
"global",
|
||||||
|
"which_key",
|
||||||
"session",
|
"session",
|
||||||
"prompt",
|
"prompt",
|
||||||
"autocomplete",
|
"autocomplete",
|
||||||
@@ -480,6 +499,9 @@ test("legacy keybinds transform into semantic keymap sections", async () => {
|
|||||||
"home_tips",
|
"home_tips",
|
||||||
])
|
])
|
||||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
||||||
|
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe(
|
||||||
|
"ctrl+alt+k",
|
||||||
|
)
|
||||||
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
||||||
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
|
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
|
||||||
"ctrl+j",
|
"ctrl+j",
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ export type TuiSidebarFileItem = {
|
|||||||
|
|
||||||
export type TuiHostSlotMap = {
|
export type TuiHostSlotMap = {
|
||||||
app: {}
|
app: {}
|
||||||
|
app_bottom: {}
|
||||||
home_logo: {}
|
home_logo: {}
|
||||||
home_prompt: {
|
home_prompt: {
|
||||||
workspace_id?: string
|
workspace_id?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user