@agentprojectcontext/apx 1.15.5 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/package.json +40 -5
  2. package/src/cli/commands/log.js +113 -0
  3. package/src/cli/commands/overlay.js +253 -0
  4. package/src/cli/commands/sys.js +88 -16
  5. package/src/cli/index.js +23 -1
  6. package/src/cli/terminal-chat/renderer.js +71 -56
  7. package/src/cli-ts/commands/agent.ts +173 -0
  8. package/src/cli-ts/commands/chat.ts +119 -0
  9. package/src/cli-ts/commands/daemon.ts +112 -0
  10. package/src/cli-ts/commands/exec.ts +109 -0
  11. package/src/cli-ts/commands/mcp.ts +235 -0
  12. package/src/cli-ts/commands/session.ts +224 -0
  13. package/src/cli-ts/commands/status.ts +61 -0
  14. package/src/cli-ts/http.ts +36 -0
  15. package/src/cli-ts/index.ts +73 -0
  16. package/src/cli-ts/ui.ts +107 -0
  17. package/src/core/logging.js +81 -0
  18. package/src/daemon/api.js +58 -0
  19. package/src/daemon/engines/anthropic.js +60 -1
  20. package/src/daemon/engines/index.js +2 -1
  21. package/src/daemon/engines/ollama.js +70 -3
  22. package/src/daemon/index.js +58 -0
  23. package/src/daemon/overlay-ws.js +40 -0
  24. package/src/daemon/plugins/index.js +2 -1
  25. package/src/daemon/plugins/overlay.js +177 -0
  26. package/src/daemon/plugins/telegram.js +15 -3
  27. package/src/daemon/super-agent.js +102 -19
  28. package/src/daemon/transcription.js +262 -59
  29. package/src/daemon/wakeup.js +14 -19
  30. package/src/daemon/whisper-server.py +57 -6
  31. package/src/overlay/index.html +44 -0
  32. package/src/overlay/main.js +480 -0
  33. package/src/overlay/package.json +3 -0
  34. package/src/overlay/preload.js +34 -0
  35. package/src/overlay/renderer.js +371 -0
  36. package/src/overlay/style.css +250 -0
  37. package/src/tui/_shims/cli-error.ts +6 -0
  38. package/src/tui/_shims/cli-logo.ts +18 -0
  39. package/src/tui/_shims/cli-ui.ts +1 -0
  40. package/src/tui/_shims/config-console-state.ts +7 -0
  41. package/src/tui/_shims/core-any.ts +30 -0
  42. package/src/tui/_shims/core-binary.ts +13 -0
  43. package/src/tui/_shims/core-flag.ts +3 -0
  44. package/src/tui/_shims/core-log.ts +14 -0
  45. package/src/tui/_shims/lsp-language.ts +1 -0
  46. package/src/tui/_shims/opencode-any.ts +135 -0
  47. package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
  48. package/src/tui/_shims/plugin-tui.ts +13 -0
  49. package/src/tui/_shims/provider-provider.ts +10 -0
  50. package/src/tui/_shims/session-retry.ts +1 -0
  51. package/src/tui/_shims/session-schema.ts +15 -0
  52. package/src/tui/_shims/session-session.ts +3 -0
  53. package/src/tui/_shims/snapshot.ts +4 -0
  54. package/src/tui/_shims/tool-any.ts +18 -0
  55. package/src/tui/_shims/util-error.ts +7 -0
  56. package/src/tui/_shims/util-filesystem.ts +79 -0
  57. package/src/tui/_shims/util-format.ts +7 -0
  58. package/src/tui/_shims/util-iife.ts +3 -0
  59. package/src/tui/_shims/util-locale.ts +10 -0
  60. package/src/tui/_shims/util-process.ts +38 -0
  61. package/src/tui/app.tsx +783 -0
  62. package/src/tui/asset/charge.wav +0 -0
  63. package/src/tui/asset/pulse-a.wav +0 -0
  64. package/src/tui/asset/pulse-b.wav +0 -0
  65. package/src/tui/asset/pulse-c.wav +0 -0
  66. package/src/tui/attach.ts +100 -0
  67. package/src/tui/component/bg-pulse-render.ts +436 -0
  68. package/src/tui/component/bg-pulse.tsx +99 -0
  69. package/src/tui/component/border.tsx +21 -0
  70. package/src/tui/component/dialog-agent.tsx +31 -0
  71. package/src/tui/component/dialog-console-org.tsx +103 -0
  72. package/src/tui/component/dialog-mcp.tsx +85 -0
  73. package/src/tui/component/dialog-model.tsx +175 -0
  74. package/src/tui/component/dialog-provider.tsx +456 -0
  75. package/src/tui/component/dialog-retry-action.tsx +160 -0
  76. package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
  77. package/src/tui/component/dialog-session-list.tsx +323 -0
  78. package/src/tui/component/dialog-session-rename.tsx +31 -0
  79. package/src/tui/component/dialog-skill.tsx +36 -0
  80. package/src/tui/component/dialog-stash.tsx +87 -0
  81. package/src/tui/component/dialog-status.tsx +168 -0
  82. package/src/tui/component/dialog-tag.tsx +44 -0
  83. package/src/tui/component/dialog-theme-list.tsx +50 -0
  84. package/src/tui/component/dialog-variant.tsx +39 -0
  85. package/src/tui/component/dialog-workspace-create.tsx +302 -0
  86. package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
  87. package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
  88. package/src/tui/component/error-component.tsx +92 -0
  89. package/src/tui/component/logo.tsx +896 -0
  90. package/src/tui/component/plugin-route-missing.tsx +14 -0
  91. package/src/tui/component/prompt/autocomplete.tsx +869 -0
  92. package/src/tui/component/prompt/cwd.ts +0 -0
  93. package/src/tui/component/prompt/frecency.tsx +90 -0
  94. package/src/tui/component/prompt/history.tsx +108 -0
  95. package/src/tui/component/prompt/index.tsx +1809 -0
  96. package/src/tui/component/prompt/part.ts +16 -0
  97. package/src/tui/component/prompt/stash.tsx +101 -0
  98. package/src/tui/component/prompt/traits.ts +35 -0
  99. package/src/tui/component/spinner.tsx +24 -0
  100. package/src/tui/component/startup-loading.tsx +63 -0
  101. package/src/tui/component/todo-item.tsx +32 -0
  102. package/src/tui/component/use-connected.tsx +9 -0
  103. package/src/tui/component/workspace-label.tsx +19 -0
  104. package/src/tui/config/cwd.ts +5 -0
  105. package/src/tui/config/keybind.ts +432 -0
  106. package/src/tui/config/tui-migrate.ts +154 -0
  107. package/src/tui/config/tui-schema.ts +34 -0
  108. package/src/tui/config/tui.ts +46 -0
  109. package/src/tui/context/aggregate-failures.ts +34 -0
  110. package/src/tui/context/args.tsx +15 -0
  111. package/src/tui/context/command-palette.tsx +163 -0
  112. package/src/tui/context/directory.ts +15 -0
  113. package/src/tui/context/editor-zed.ts +283 -0
  114. package/src/tui/context/editor.ts +468 -0
  115. package/src/tui/context/event-apx.ts +22 -0
  116. package/src/tui/context/event.ts +6 -0
  117. package/src/tui/context/exit.tsx +60 -0
  118. package/src/tui/context/helper.tsx +25 -0
  119. package/src/tui/context/kv.tsx +81 -0
  120. package/src/tui/context/local.tsx +608 -0
  121. package/src/tui/context/path-format.tsx +39 -0
  122. package/src/tui/context/project-apx.tsx +48 -0
  123. package/src/tui/context/project.tsx +7 -0
  124. package/src/tui/context/prompt.tsx +18 -0
  125. package/src/tui/context/route.tsx +52 -0
  126. package/src/tui/context/sdk-apx.tsx +185 -0
  127. package/src/tui/context/sdk.tsx +6 -0
  128. package/src/tui/context/sync-apx.tsx +178 -0
  129. package/src/tui/context/sync-v2.tsx +16 -0
  130. package/src/tui/context/sync.tsx +118 -0
  131. package/src/tui/context/theme/aura.json +69 -0
  132. package/src/tui/context/theme/ayu.json +80 -0
  133. package/src/tui/context/theme/carbonfox.json +248 -0
  134. package/src/tui/context/theme/catppuccin-frappe.json +230 -0
  135. package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
  136. package/src/tui/context/theme/catppuccin.json +112 -0
  137. package/src/tui/context/theme/cobalt2.json +225 -0
  138. package/src/tui/context/theme/cursor.json +249 -0
  139. package/src/tui/context/theme/dracula.json +219 -0
  140. package/src/tui/context/theme/everforest.json +241 -0
  141. package/src/tui/context/theme/flexoki.json +237 -0
  142. package/src/tui/context/theme/github.json +233 -0
  143. package/src/tui/context/theme/gruvbox.json +242 -0
  144. package/src/tui/context/theme/kanagawa.json +77 -0
  145. package/src/tui/context/theme/lucent-orng.json +234 -0
  146. package/src/tui/context/theme/material.json +235 -0
  147. package/src/tui/context/theme/matrix.json +77 -0
  148. package/src/tui/context/theme/mercury.json +252 -0
  149. package/src/tui/context/theme/monokai.json +221 -0
  150. package/src/tui/context/theme/nightowl.json +221 -0
  151. package/src/tui/context/theme/nord.json +223 -0
  152. package/src/tui/context/theme/one-dark.json +84 -0
  153. package/src/tui/context/theme/opencode.json +245 -0
  154. package/src/tui/context/theme/orng.json +249 -0
  155. package/src/tui/context/theme/osaka-jade.json +93 -0
  156. package/src/tui/context/theme/palenight.json +222 -0
  157. package/src/tui/context/theme/rosepine.json +234 -0
  158. package/src/tui/context/theme/solarized.json +223 -0
  159. package/src/tui/context/theme/synthwave84.json +226 -0
  160. package/src/tui/context/theme/tokyonight.json +243 -0
  161. package/src/tui/context/theme/vercel.json +245 -0
  162. package/src/tui/context/theme/vesper.json +218 -0
  163. package/src/tui/context/theme/zenburn.json +223 -0
  164. package/src/tui/context/theme.tsx +1247 -0
  165. package/src/tui/context/tui-config.tsx +9 -0
  166. package/src/tui/event.ts +16 -0
  167. package/src/tui/feature-plugins/home/footer.tsx +94 -0
  168. package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
  169. package/src/tui/feature-plugins/home/tips.tsx +59 -0
  170. package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
  171. package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
  172. package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
  173. package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
  174. package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
  175. package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
  176. package/src/tui/feature-plugins/system/plugins.tsx +269 -0
  177. package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
  178. package/src/tui/feature-plugins/system/which-key.tsx +608 -0
  179. package/src/tui/keymap.tsx +166 -0
  180. package/src/tui/layer.ts +6 -0
  181. package/src/tui/plugin/api.tsx +381 -0
  182. package/src/tui/plugin/command-shim.ts +109 -0
  183. package/src/tui/plugin/internal.ts +33 -0
  184. package/src/tui/plugin/runtime.ts +1069 -0
  185. package/src/tui/plugin/slots.tsx +60 -0
  186. package/src/tui/routes/home.tsx +96 -0
  187. package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
  188. package/src/tui/routes/session/dialog-message.tsx +108 -0
  189. package/src/tui/routes/session/dialog-subagent.tsx +26 -0
  190. package/src/tui/routes/session/dialog-timeline.tsx +47 -0
  191. package/src/tui/routes/session/footer.tsx +91 -0
  192. package/src/tui/routes/session/index.tsx +188 -0
  193. package/src/tui/routes/session/permission.tsx +722 -0
  194. package/src/tui/routes/session/question.tsx +490 -0
  195. package/src/tui/routes/session/sidebar.tsx +102 -0
  196. package/src/tui/routes/session/subagent-footer.tsx +133 -0
  197. package/src/tui/run.ts +84 -0
  198. package/src/tui/thread.ts +261 -0
  199. package/src/tui/tsconfig.json +40 -0
  200. package/src/tui/ui/dialog-alert.tsx +66 -0
  201. package/src/tui/ui/dialog-confirm.tsx +108 -0
  202. package/src/tui/ui/dialog-export-options.tsx +217 -0
  203. package/src/tui/ui/dialog-help.tsx +40 -0
  204. package/src/tui/ui/dialog-prompt.tsx +101 -0
  205. package/src/tui/ui/dialog-select.tsx +553 -0
  206. package/src/tui/ui/dialog.tsx +211 -0
  207. package/src/tui/ui/link.tsx +34 -0
  208. package/src/tui/ui/spinner.ts +368 -0
  209. package/src/tui/ui/toast.tsx +111 -0
  210. package/src/tui/util/clipboard.ts +217 -0
  211. package/src/tui/util/editor.ts +37 -0
  212. package/src/tui/util/model.ts +23 -0
  213. package/src/tui/util/provider-origin.ts +7 -0
  214. package/src/tui/util/revert-diff.ts +18 -0
  215. package/src/tui/util/scroll.ts +25 -0
  216. package/src/tui/util/selection.ts +65 -0
  217. package/src/tui/util/signal.ts +41 -0
  218. package/src/tui/util/sound.ts +156 -0
  219. package/src/tui/util/transcript.ts +112 -0
  220. package/src/tui/validate-session.ts +29 -0
  221. package/src/tui/win32.ts +130 -0
  222. package/src/tui/worker.ts +104 -0
@@ -0,0 +1,608 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
3
+ import { useTerminalDimensions } from "@opentui/solid"
4
+ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
5
+ import { useBindings, useKeymapSelector } from "../../keymap"
6
+ import type { ActiveKey } from "@opentui/keymap"
7
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
8
+ import type { InternalTuiPlugin } from "../../plugin/internal"
9
+
10
+ const command = {
11
+ toggle: "which-key.toggle",
12
+ toggleLayout: "which-key.layout.toggle",
13
+ togglePending: "which-key.pending.toggle",
14
+ groupPrevious: "which-key.group.previous",
15
+ groupNext: "which-key.group.next",
16
+ scrollUp: "which-key.scroll.up",
17
+ scrollDown: "which-key.scroll.down",
18
+ pageUp: "which-key.page.up",
19
+ pageDown: "which-key.page.down",
20
+ home: "which-key.home",
21
+ end: "which-key.end",
22
+ } as const
23
+
24
+ const LAYER_PRIORITY = 900
25
+ const KV_LAYOUT = "which_key_layout"
26
+ const KV_PENDING_PREVIEW = "which_key_pending_preview"
27
+ const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
28
+ const scrollCommands = [
29
+ command.scrollUp,
30
+ command.scrollDown,
31
+ command.pageUp,
32
+ command.pageDown,
33
+ command.home,
34
+ command.end,
35
+ ] as const
36
+ const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
37
+ const COLUMN_GAP = 4
38
+ const TAB_GAP = 3
39
+ const MIN_TAB_GAP = 1
40
+ const TAB_CONTENT_GAP = 1
41
+ const MIN_COLUMN_WIDTH = 28
42
+ const MAX_COLUMN_WIDTH = 44
43
+ const PANEL_HEIGHT_RATIO = 0.3
44
+ const MIN_PANEL_HEIGHT = 8
45
+ const MAX_PANEL_HEIGHT = 16
46
+ const PANEL_TOP_PADDING = 1
47
+ const FOOTER_HEIGHT = 1
48
+ const FOOTER_MARGIN = 1
49
+ const UNKNOWN = "Unknown"
50
+
51
+ type Layout = "dock" | "overlay"
52
+
53
+ type Color = RGBA | string
54
+
55
+ type Skin = {
56
+ panel: Color
57
+ text: Color
58
+ muted: Color
59
+ subtle: Color
60
+ key: Color
61
+ accent: Color
62
+ tab: Color
63
+ tabText: Color
64
+ }
65
+
66
+ type Entry = {
67
+ type: "entry"
68
+ key: string
69
+ label: string
70
+ group: string
71
+ continues: boolean
72
+ }
73
+
74
+ type Group = {
75
+ label: string
76
+ entries: Entry[]
77
+ }
78
+
79
+ type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
80
+
81
+ type GroupHeader = {
82
+ type: "group"
83
+ label: string
84
+ }
85
+
86
+ type Item = Entry | GroupHeader
87
+
88
+ function text(value: unknown) {
89
+ if (typeof value !== "string") return undefined
90
+ const trimmed = value.trim()
91
+ return trimmed || undefined
92
+ }
93
+
94
+ function ink(api: TuiPluginApi, name: string, fallback: string): Color {
95
+ const value = Reflect.get(api.theme.current, name)
96
+ if (typeof value === "string") return value
97
+ if (value instanceof RGBA) return value
98
+ return fallback
99
+ }
100
+
101
+ function skin(api: TuiPluginApi): Skin {
102
+ return {
103
+ panel: ink(api, "backgroundMenu", "#1c1c1c"),
104
+ text: ink(api, "text", "#f0f0f0"),
105
+ muted: ink(api, "textMuted", "#a5a5a5"),
106
+ subtle: ink(api, "borderSubtle", "#6f6f6f"),
107
+ key: ink(api, "warning", "#ffd75f"),
108
+ accent: ink(api, "primary", "#5f87ff"),
109
+ tab: ink(api, "primary", "#5f87ff"),
110
+ tabText: ink(api, "selectedListItemText", "#ffffff"),
111
+ }
112
+ }
113
+
114
+ function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
115
+ if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
116
+ return (
117
+ text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
118
+ )
119
+ }
120
+
121
+ function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
122
+ if (active.continues) return "System"
123
+ return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
124
+ }
125
+
126
+ function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
127
+ const key = api.keys.formatSequence([
128
+ {
129
+ stroke: active.stroke,
130
+ display: active.display,
131
+ tokenName: active.tokenName,
132
+ },
133
+ ])
134
+ const label = activeKeyLabel(active)
135
+ return {
136
+ type: "entry",
137
+ key,
138
+ label: active.continues ? `+${label}` : label,
139
+ group: activeKeyGroup(active),
140
+ continues: active.continues,
141
+ }
142
+ }
143
+
144
+ function grouped(entries: Entry[]): Group[] {
145
+ const map = new Map<string, Entry[]>()
146
+ for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
147
+ return [...map]
148
+ .map(([label, entries]) => ({
149
+ label,
150
+ entries: entries.toSorted(
151
+ (a, b) =>
152
+ Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
153
+ ),
154
+ }))
155
+ .toSorted((a, b) => a.label.localeCompare(b.label))
156
+ }
157
+
158
+ function commandShortcut(api: TuiPluginApi, name: string) {
159
+ return useKeymapSelector((keymap) =>
160
+ api.keys.formatSequence(
161
+ keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence,
162
+ ),
163
+ )
164
+ }
165
+
166
+ function layout(value: unknown): Layout {
167
+ if (value === "overlay") return "overlay"
168
+ return "dock"
169
+ }
170
+
171
+ function HomeHint(props: { api: TuiPluginApi }) {
172
+ const trigger = commandShortcut(props.api, command.toggle)
173
+ const look = createMemo(() => skin(props.api))
174
+
175
+ return (
176
+ <box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
177
+ <text fg={look().muted} wrapMode="none">
178
+ Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
179
+ </text>
180
+ </box>
181
+ )
182
+ }
183
+
184
+ function WhichKeyPanel(props: {
185
+ api: TuiPluginApi
186
+ layout: Layout
187
+ mode: () => Layout
188
+ pendingPreview: () => boolean
189
+ pinned: () => boolean
190
+ }) {
191
+ const dimensions = useTerminalDimensions()
192
+ const [offset, setOffset] = createSignal(0)
193
+ const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
194
+ const pending = useKeymapSelector((keymap) => keymap.getPendingSequence())
195
+ const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
196
+ const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
197
+ const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
198
+ const visible = createMemo(() => props.pinned() || pendingAutoVisible())
199
+ const pendingMode = createMemo(() => visible() && pendingActive())
200
+ const left = 0
201
+ const width = createMemo(() => Math.max(1, dimensions().width))
202
+ const panelHeight = createMemo(() =>
203
+ Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
204
+ )
205
+ const contentWidth = createMemo(() => Math.max(1, width() - 2))
206
+ const columns = createMemo(() =>
207
+ Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
208
+ )
209
+ const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
210
+ const groups = createMemo(() => grouped(entries()))
211
+ const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
212
+ const headerVisible = createMemo(() => tabsVisible() || pendingMode())
213
+ const footerVisible = createMemo(() => !pendingMode())
214
+ const rows = createMemo(() =>
215
+ Math.max(
216
+ 1,
217
+ panelHeight() -
218
+ PANEL_TOP_PADDING -
219
+ (headerVisible() ? 1 : 0) -
220
+ (tabsVisible() ? TAB_CONTENT_GAP : 0) -
221
+ (footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
222
+ ),
223
+ )
224
+ const pageSize = createMemo(() => rows() * columns())
225
+ const currentGroup = createMemo(() => {
226
+ const group = activeGroup()
227
+ return groups().find((item) => item.label === group) ?? groups()[0]
228
+ })
229
+ const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
230
+ const items = createMemo<Item[]>(() => {
231
+ if (!pendingMode()) return activeEntries()
232
+ return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
233
+ })
234
+ const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
235
+ const shown = createMemo(() => {
236
+ const columnsItems: Item[][] = []
237
+ let index = offset()
238
+ for (let column = 0; column < columns() && index < items().length; column++) {
239
+ const list: Item[] = []
240
+ while (list.length < rows() && index < items().length) {
241
+ list.push(items()[index]!)
242
+ index += 1
243
+ }
244
+ columnsItems.push(list)
245
+ }
246
+ return columnsItems
247
+ })
248
+ const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
249
+ const trigger = commandShortcut(props.api, command.toggle)
250
+ const modeTrigger = commandShortcut(props.api, command.toggleLayout)
251
+ const upActive = createMemo(() => offset() > 0)
252
+ const downActive = createMemo(() => offset() < maxOffset())
253
+ const scrollable = createMemo(() => maxOffset() > 0)
254
+ const headerItems = createMemo<HeaderItem[]>(() => [
255
+ ...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
256
+ ...(scrollable() ? [{ type: "scroll" as const }] : []),
257
+ ])
258
+ const tabGap = createMemo(() => {
259
+ const itemCount = headerItems().length
260
+ if (itemCount <= 1) return 0
261
+ const itemWidth = headerItems().reduce(
262
+ (sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
263
+ 0,
264
+ )
265
+ return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
266
+ })
267
+ const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
268
+ const look = createMemo(() => skin(props.api))
269
+ const columnWidth = createMemo(() =>
270
+ Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
271
+ )
272
+ const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
273
+ const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
274
+ const moveGroup = (delta: number) => {
275
+ if (pendingMode()) return
276
+ const list = groups()
277
+ if (!list.length) return
278
+ const index = Math.max(
279
+ 0,
280
+ list.findIndex((item) => item.label === currentGroup()?.label),
281
+ )
282
+ setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
283
+ setOffset(0)
284
+ }
285
+
286
+ useBindings(() => ({
287
+ priority: 1000,
288
+ enabled: visible(),
289
+ commands: [
290
+ {
291
+ name: command.groupPrevious,
292
+ title: "Previous key binding group",
293
+ desc: "Show the previous which-key group",
294
+ category: "System",
295
+ run() {
296
+ moveGroup(-1)
297
+ },
298
+ },
299
+ {
300
+ name: command.groupNext,
301
+ title: "Next key binding group",
302
+ desc: "Show the next which-key group",
303
+ category: "System",
304
+ run() {
305
+ moveGroup(1)
306
+ },
307
+ },
308
+ {
309
+ name: command.scrollUp,
310
+ title: "Scroll key bindings up",
311
+ desc: "Scroll the which-key panel up",
312
+ category: "System",
313
+ run() {
314
+ scroll(-columns())
315
+ },
316
+ },
317
+ {
318
+ name: command.scrollDown,
319
+ title: "Scroll key bindings down",
320
+ desc: "Scroll the which-key panel down",
321
+ category: "System",
322
+ run() {
323
+ scroll(columns())
324
+ },
325
+ },
326
+ {
327
+ name: command.pageUp,
328
+ title: "Page key bindings up",
329
+ desc: "Page the which-key panel up",
330
+ category: "System",
331
+ run() {
332
+ scroll(-pageSize())
333
+ },
334
+ },
335
+ {
336
+ name: command.pageDown,
337
+ title: "Page key bindings down",
338
+ desc: "Page the which-key panel down",
339
+ category: "System",
340
+ run() {
341
+ scroll(pageSize())
342
+ },
343
+ },
344
+ {
345
+ name: command.home,
346
+ title: "First key binding",
347
+ desc: "Jump to the first which-key binding",
348
+ category: "System",
349
+ run() {
350
+ setOffset(0)
351
+ },
352
+ },
353
+ {
354
+ name: command.end,
355
+ title: "Last key binding",
356
+ desc: "Jump to the last which-key binding",
357
+ category: "System",
358
+ run() {
359
+ setOffset(maxOffset())
360
+ },
361
+ },
362
+ ],
363
+ bindings: pendingMode()
364
+ ? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
365
+ : props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
366
+ }))
367
+
368
+ createEffect(() => {
369
+ if (pendingMode()) return
370
+ const group = currentGroup()
371
+ if (group?.label === activeGroup()) return
372
+ setActiveGroup(group?.label)
373
+ })
374
+
375
+ createEffect(() => {
376
+ if (pendingMode()) return
377
+ activeGroup()
378
+ setOffset(0)
379
+ })
380
+
381
+ createEffect(() => {
382
+ if (!visible()) setOffset(0)
383
+ })
384
+
385
+ createEffect(() => {
386
+ pending()
387
+ setOffset(0)
388
+ })
389
+
390
+ createEffect(() => {
391
+ setOffset((value) => clamp(value))
392
+ })
393
+
394
+ return (
395
+ <Show when={visible()}>
396
+ <box
397
+ position={props.layout === "overlay" ? "absolute" : "relative"}
398
+ zIndex={3500}
399
+ left={left}
400
+ bottom={props.layout === "overlay" ? 0 : undefined}
401
+ width={dimensions().width}
402
+ height={panelHeight()}
403
+ backgroundColor={look().panel}
404
+ paddingLeft={1}
405
+ paddingRight={1}
406
+ paddingTop={1}
407
+ flexShrink={0}
408
+ flexDirection="column"
409
+ >
410
+ <Show when={headerVisible()}>
411
+ <box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
412
+ <For each={headerItems()}>
413
+ {(item) => (
414
+ <Show
415
+ when={item.type === "tab" ? item.group : undefined}
416
+ fallback={
417
+ <box flexShrink={0}>
418
+ <text wrapMode="none">
419
+ <span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
420
+ <span style={{ fg: look().muted }}> </span>
421
+ <span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
422
+ </text>
423
+ </box>
424
+ }
425
+ >
426
+ {(group) => {
427
+ const selected = createMemo(() => currentGroup()?.label === group().label)
428
+ return (
429
+ <box
430
+ paddingLeft={1}
431
+ paddingRight={1}
432
+ flexShrink={0}
433
+ backgroundColor={selected() ? look().tab : undefined}
434
+ onMouseDown={() => {
435
+ setActiveGroup(group().label)
436
+ setOffset(0)
437
+ }}
438
+ >
439
+ <text
440
+ fg={selected() ? look().tabText : look().muted}
441
+ attributes={selected() ? TextAttributes.BOLD : undefined}
442
+ wrapMode="none"
443
+ >
444
+ {group().label}
445
+ </text>
446
+ </box>
447
+ )
448
+ }}
449
+ </Show>
450
+ )}
451
+ </For>
452
+ </box>
453
+ </Show>
454
+ <Show when={tabsVisible()}>
455
+ <box height={TAB_CONTENT_GAP} flexShrink={0} />
456
+ </Show>
457
+ <box height={rows()} flexShrink={0} flexDirection="column">
458
+ <Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
459
+ <For each={rowIndexes()}>
460
+ {(row) => (
461
+ <box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
462
+ <For each={shown()}>
463
+ {(column) => {
464
+ const item = createMemo(() => column[row])
465
+ const entry = createMemo(() => {
466
+ const value = item()
467
+ if (value?.type !== "entry") return undefined
468
+ return value
469
+ })
470
+ return (
471
+ <box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
472
+ <Show when={item()}>
473
+ {(value) => (
474
+ <Show
475
+ when={entry()}
476
+ fallback={
477
+ <text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
478
+ {value().label}
479
+ </text>
480
+ }
481
+ >
482
+ {(binding) => (
483
+ <>
484
+ <box flexGrow={1} minWidth={0}>
485
+ <text
486
+ fg={binding().continues ? look().accent : look().muted}
487
+ wrapMode="none"
488
+ truncate
489
+ >
490
+ {binding().label}
491
+ </text>
492
+ </box>
493
+ <box flexShrink={0}>
494
+ <text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
495
+ {binding().key}
496
+ </text>
497
+ </box>
498
+ </>
499
+ )}
500
+ </Show>
501
+ )}
502
+ </Show>
503
+ </box>
504
+ )
505
+ }}
506
+ </For>
507
+ </box>
508
+ )}
509
+ </For>
510
+ </Show>
511
+ </box>
512
+ <Show when={footerVisible()}>
513
+ <box height={FOOTER_MARGIN} flexShrink={0} />
514
+ <box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
515
+ <box>
516
+ <text fg={look().text} wrapMode="none">
517
+ toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
518
+ </text>
519
+ </box>
520
+ <box>
521
+ <text fg={look().text} wrapMode="none">
522
+ {nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
523
+ </text>
524
+ </box>
525
+ </box>
526
+ </Show>
527
+ </box>
528
+ </Show>
529
+ )
530
+ }
531
+
532
+ const tui: TuiPlugin = async (api) => {
533
+ const [pinned, setPinned] = createSignal(false)
534
+ const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
535
+ const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
536
+
537
+ api.keymap.registerLayer({
538
+ priority: LAYER_PRIORITY,
539
+ commands: [
540
+ {
541
+ name: command.toggle,
542
+ title: "Show key bindings",
543
+ desc: "Toggle which-key overlay",
544
+ category: "System",
545
+ run() {
546
+ setPinned((value) => !value)
547
+ },
548
+ },
549
+ {
550
+ name: command.toggleLayout,
551
+ title: "Toggle key bindings layout",
552
+ desc: "Switch which-key between dock and overlay mode",
553
+ category: "System",
554
+ run() {
555
+ setMode((value) => {
556
+ const next = value === "dock" ? "overlay" : "dock"
557
+ api.kv.set(KV_LAYOUT, next)
558
+ return next
559
+ })
560
+ },
561
+ },
562
+ {
563
+ name: command.togglePending,
564
+ title: "Toggle pending key preview",
565
+ desc: "Automatically show which-key for pending key sequences in overlay mode",
566
+ category: "System",
567
+ run() {
568
+ setPendingPreview((value) => {
569
+ api.kv.set(KV_PENDING_PREVIEW, !value)
570
+ return !value
571
+ })
572
+ },
573
+ },
574
+ ],
575
+ bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
576
+ })
577
+
578
+ api.slots.register({
579
+ order: 200,
580
+ slots: {
581
+ home_bottom() {
582
+ return <HomeHint api={api} />
583
+ },
584
+ app() {
585
+ return (
586
+ <Show when={mode() === "overlay"}>
587
+ <WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
588
+ </Show>
589
+ )
590
+ },
591
+ app_bottom() {
592
+ return (
593
+ <Show when={mode() === "dock"}>
594
+ <WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
595
+ </Show>
596
+ )
597
+ },
598
+ },
599
+ })
600
+ }
601
+
602
+ const plugin: InternalTuiPlugin = {
603
+ id: "which-key",
604
+ enabled: false,
605
+ tui,
606
+ }
607
+
608
+ export default plugin