@xp266/dshtui 0.1.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 (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. package/src/vendor.ts +12 -0
package/src/ui/app.tsx ADDED
@@ -0,0 +1,454 @@
1
+ import { Box, Text, useInput } from 'ink'
2
+ import type { ReactNode } from 'react'
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
4
+ import type { RefObject } from 'react'
5
+ import { permissionModeInfo } from '../theme.ts'
6
+ import { writeClipboardText } from '../terminal/clipboard.ts'
7
+ import { copySelection } from './selection/service.ts'
8
+ import type { ChatBridge } from '../chat/bridge.ts'
9
+ import type { AgentActivity } from '../chat/store.ts'
10
+ import { rowIndexFor, selectionText } from './message/layout.ts'
11
+ import { glyphs } from '../terminal/glyphs.ts'
12
+ import { chromeSelectionText } from './selection-registry.ts'
13
+ import type { ScreenCapture } from '../terminal/screen.ts'
14
+ import type { LineSelection } from '../model/selection.ts'
15
+ import { SelectionContext } from './selection.tsx'
16
+ import { useTerminalSize } from './hooks/use-terminal-size.ts'
17
+ import { useChatEvents } from './hooks/use-chat-events.ts'
18
+ import { useScroll } from './hooks/use-scroll.ts'
19
+ import { useMouseSelection, hintRegion } from './hooks/use-mouse-selection.ts'
20
+ import { isKeyConsumed } from './key-arbiter.ts'
21
+ import { KeymapGate } from './chrome/key-gate.tsx'
22
+ import { useOverlayContribution } from './contributions.ts'
23
+ import { InputBar, inputLayout, INPUT_WIDTH_OFFSET, HINT_MAX_ROWS } from './input/input-bar.tsx'
24
+ import type { InputBarHandle } from './input/input-bar.tsx'
25
+ import { COMMANDS, KNOWN_COMMAND_ARGS, commandArgHints, filterHintEntries, matchCommand, mergeCommandEntries, matchAvailableCommand, useCommandVersion } from './input/commands.ts'
26
+ import { hintArgsFor } from './chrome/hint-service.ts'
27
+ import type { CommandAvailability, CommandDef } from './input/commands.ts'
28
+ import { MESSAGE_INPUT_GAP_ROWS } from '../core/metrics.ts'
29
+ import { useComposer } from './input/use-composer.ts'
30
+ import type { ComposerSubmission } from './input/composer-fields.ts'
31
+ import { Region } from './region.tsx'
32
+ import { MessageList } from './message/message-list.tsx'
33
+ import { useHomeLogoBubble } from './home-logo.ts'
34
+ import { SpinnerTickProvider } from './spinner-tick.tsx'
35
+ import { CloseGuardContext } from './dialog/dialog.tsx'
36
+ import type { DialogHandle } from './dialog/dialog.tsx'
37
+ import { registerBuiltinWindows } from './windows-builtin.tsx'
38
+ import { registerWindowServices, registerTodosService } from './window-services-bridge.ts'
39
+ import { useAvailableWindows } from './use-available-windows.ts'
40
+ import type { ActivePanel, InteractionStore } from '../chat/interactions.ts'
41
+ import type { PanelPointerHandle } from './panels/surface.tsx'
42
+ import { registerBuiltinPanels } from './panels-builtin.tsx'
43
+ import { isTodoActive, todoProgress } from '../chat/todo-view.ts'
44
+ import type { TodoItemLike } from '../chat/todo-view.ts'
45
+ import { useOverlayStack } from './overlay.ts'
46
+ import { handleKeyContributions } from './keymap.ts'
47
+ import { StatusBar } from './chrome/status-bar.tsx'
48
+
49
+ const FORCE_EXIT_DELAY_MS = 6000
50
+ const INTERRUPT_ARM_MS = 3000
51
+
52
+ const noopSubscribe = () => () => {}
53
+ const nullSnapshot = () => null
54
+
55
+ function useActivePanel(interactions: InteractionStore | undefined): ActivePanel | null {
56
+ return useSyncExternalStore(
57
+ interactions?.subscribe ?? noopSubscribe,
58
+ interactions?.getSnapshot ?? nullSnapshot,
59
+ )
60
+ }
61
+
62
+ interface AppProps {
63
+ bridge?: ChatBridge
64
+ screen?: ScreenCapture
65
+ themeTick?: number
66
+ }
67
+
68
+ export function App({ bridge, screen, themeTick = 0 }: AppProps) {
69
+ const { columns, rows } = useTerminalSize()
70
+ const overlays = useOverlayStack<string>()
71
+ const dialog: string | null = overlays.top ?? null
72
+ const [, setSessionTick] = useState(0)
73
+ const { messages, modelName, setModelName, updateMessages, resetChat, activity, todos, retryStatus, streamedChars, registryCommands } = useChatEvents(bridge, dialog !== null)
74
+ const windows = useAvailableWindows()
75
+ useEffect(() => {
76
+ if (bridge === undefined) return
77
+ const offServices = registerWindowServices(bridge)
78
+ const offPanels = registerBuiltinPanels({ bridge })
79
+ const offWindows = registerBuiltinWindows({
80
+ onModelSelected: (_provider, model) => setModelName(model),
81
+ onAddProvider: () => {
82
+ overlays.pop()
83
+ overlays.push('providers')
84
+ },
85
+ onBeforeSessionSelected: () => {
86
+ resetChat()
87
+ clearSelection()
88
+ },
89
+ onSessionSelected: () => {
90
+ setSessionTick(tick => tick + 1)
91
+ applyScroll(Infinity)
92
+ },
93
+ onNewSession: startNewSession,
94
+ })
95
+ void bridge.listSessions().catch(() => {})
96
+ return () => {
97
+ offWindows()
98
+ offPanels()
99
+ offServices()
100
+ }
101
+ }, [bridge])
102
+ useEffect(() => {
103
+ return registerTodosService(todos)
104
+ }, [todos])
105
+ const todoActive = isTodoActive(todos)
106
+ const todoBadge = todoProgress(todos)
107
+ const running = activity.running
108
+ const dialogRef = useRef<DialogHandle | null>(null)
109
+ const inputRef = useRef<InputBarHandle | null>(null)
110
+ const panelHandleRef = useRef<PanelPointerHandle | null>(null)
111
+ const exiting = useRef(false)
112
+ const sendRef = useRef<(submission: ComposerSubmission) => void>(() => {})
113
+ const isCommandAvailable = useCallback<CommandAvailability>(
114
+ command => command.id !== 'todo' || todoActive,
115
+ [todoActive],
116
+ )
117
+ const busy = running || activity.compacting
118
+ const [escArmed, setEscArmed] = useState(false)
119
+ const escAtRef = useRef(0)
120
+ const escTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
121
+ const disarmInterrupt = () => {
122
+ escAtRef.current = 0
123
+ setEscArmed(false)
124
+ if (escTimerRef.current !== null) {
125
+ clearTimeout(escTimerRef.current)
126
+ escTimerRef.current = null
127
+ }
128
+ }
129
+ useEffect(() => {
130
+ if (!running) disarmInterrupt()
131
+ return disarmInterrupt
132
+ }, [running])
133
+ const contentWidth = columns - INPUT_WIDTH_OFFSET
134
+ const panel = useActivePanel(bridge?.interactions)
135
+ const overlayContribution = useOverlayContribution(dialog)
136
+ const [panelHeight, setPanelHeight] = useState(7)
137
+ const composerInteractive = dialog === null && panel === null
138
+ const commandVersion = useCommandVersion()
139
+ const windowCommands = useMemo(
140
+ () => {
141
+ const staticNames = new Set(COMMANDS.map(command => command.command))
142
+ return windows
143
+ .filter(contribution => contribution.command !== undefined && !staticNames.has(`/${contribution.command.name}`))
144
+ .map(contribution => ({ id: contribution.id, command: `/${contribution.command!.name}`, description: contribution.command!.description }))
145
+ },
146
+ [windows],
147
+ )
148
+ const commandEntries = useMemo(
149
+ () => mergeCommandEntries([...COMMANDS.filter(isCommandAvailable), ...windowCommands], registryCommands),
150
+ [isCommandAvailable, windowCommands, registryCommands, commandVersion],
151
+ )
152
+ const registryNames = useMemo(
153
+ () => new Set(registryCommands.map(entry => `/${entry.name}`)),
154
+ [registryCommands],
155
+ )
156
+ const permissionPresetsRef = useRef<string[] | undefined>(undefined)
157
+ const listCommandArgs = useCallback(async (name: string): Promise<string[]> => {
158
+ // Plugin providers run first; builtins (registry hints, known args,
159
+ // permission presets) act as the fallback chain.
160
+ const contributed = await hintArgsFor(name, () => [])
161
+ if (contributed.length > 0) return contributed
162
+ const registered = commandArgHints(name)
163
+ if (registered !== undefined) return [...registered]
164
+ const known = KNOWN_COMMAND_ARGS[name]
165
+ if (known !== undefined) return [...known]
166
+ if (name !== 'permission') return []
167
+ const cached = permissionPresetsRef.current
168
+ if (cached !== undefined) return [...cached]
169
+ const list = await bridge?.listPermissionPresets?.() ?? []
170
+ permissionPresetsRef.current = list
171
+ return list
172
+ }, [bridge])
173
+ const { value, cursor, hintOpen, commandIndex, api } = useComposer(
174
+ submission => sendRef.current(submission),
175
+ composerInteractive,
176
+ contentWidth,
177
+ () => bridge?.cyclePermission(),
178
+ commandEntries,
179
+ listCommandArgs,
180
+ )
181
+ const commands = useMemo(() => filterHintEntries(commandEntries, value), [commandEntries, value])
182
+ const layout = inputLayout(value, cursor, columns)
183
+ const permissionMode = bridge?.permissionMode() ?? 'workspace-write'
184
+ const permissionChrome = permissionModeInfo(permissionMode)
185
+ const bottomHeight = panel === null ? layout.barHeight : panelHeight + 1
186
+ const inputHeight = bottomHeight
187
+ const messageHeight = Math.max(1, rows - inputHeight - MESSAGE_INPUT_GAP_ROWS)
188
+ // The home page is a chat-styled surface for special messages; the logo
189
+ // bubble is its content. Any real message switches to the chat list and
190
+ // the logo disappears.
191
+ const logoBubble = useHomeLogoBubble(columns, messageHeight)
192
+ const displayMessages = messages.length === 0 ? [logoBubble] : messages
193
+ const total = rowIndexFor(displayMessages, columns).total
194
+ const { scrollTop, applyScroll, getScroll } = useScroll(total, messageHeight)
195
+ const startNewSession = () => {
196
+ if (!bridge) return
197
+ resetChat()
198
+ clearSelection()
199
+ applyScroll(Infinity)
200
+ void Promise.resolve(bridge.newSession())
201
+ .then(() => setSessionTick(tick => tick + 1))
202
+ .catch(() => {})
203
+ }
204
+ const runCommand = (def: CommandDef) => {
205
+ if (def.run !== undefined) {
206
+ def.run()
207
+ return
208
+ }
209
+ switch (def.id) {
210
+ case 'new':
211
+ startNewSession()
212
+ return
213
+ case 'todo':
214
+ if (bridge && todoActive) overlays.push('todo')
215
+ return
216
+ default:
217
+ if (bridge) overlays.push(def.id)
218
+ return
219
+ }
220
+ }
221
+ const handleSend = (submission: ComposerSubmission) => {
222
+ const text = submission.text
223
+ const matched = matchAvailableCommand(text, isCommandAvailable, windowCommands)
224
+ if (matched !== undefined) {
225
+ runCommand(matched)
226
+ return
227
+ }
228
+ if (matchCommand(text) !== undefined) return
229
+ if (registryNames.has(text.split(/\s+/, 1)[0] ?? '')) {
230
+ void bridge?.executeCommandLine(text)
231
+ applyScroll(Infinity)
232
+ return
233
+ }
234
+ bridge?.send(submission.text, submission.images)
235
+ applyScroll(Infinity)
236
+ }
237
+ sendRef.current = handleSend
238
+ const showHint = dialog === null && hintOpen && commands.length > 0
239
+ const maxVisible = Math.max(1, Math.min(HINT_MAX_ROWS, rows - inputHeight - 1))
240
+ const hintPrevIndexRef = useRef(0)
241
+ const hintDir: -1 | 1 = showHint && commandIndex !== hintPrevIndexRef.current
242
+ ? commandIndex > hintPrevIndexRef.current ? 1 : -1
243
+ : 1
244
+ const hintAnchor = hintDir === 1 ? Math.floor(maxVisible / 2) : Math.floor((maxVisible - 1) / 2)
245
+ const hintVisibleStart = Math.max(0, Math.min(commandIndex - hintAnchor, Math.max(0, commands.length - maxVisible)))
246
+ hintPrevIndexRef.current = commandIndex
247
+ const hintState = showHint
248
+ ? {
249
+ commands: commands.slice(hintVisibleStart, hintVisibleStart + maxVisible),
250
+ selectedIndex: commandIndex - hintVisibleStart,
251
+ startIndex: hintVisibleStart,
252
+ }
253
+ : null
254
+ const handleToggle = (id: string) => {
255
+ updateMessages(current => current.map(message =>
256
+ message.kind === 'collapsible' && message.id === id
257
+ ? { ...message, collapsed: !message.collapsed }
258
+ : message,
259
+ ))
260
+ }
261
+ const hintIndexAt = (y: number): number | undefined => {
262
+ const region = hintRegion(rows, hintState, dialog !== null, inputHeight)
263
+ if (region === null || hintState === null) return undefined
264
+ if (y < region.top || y > region.bottom) return undefined
265
+ const index = (hintState.startIndex ?? 0) + (y - region.top)
266
+ if (index < 0 || index >= commandEntries.length) return undefined
267
+ return index
268
+ }
269
+ const hintPendingPick = useRef<number | null>(null)
270
+ const handleHintPress = (_x: number, y: number): void => {
271
+ hintPendingPick.current = hintIndexAt(y) ?? null
272
+ }
273
+ const handleHintDragStart = (x: number, y: number): void => {
274
+ setSelection({ anchorRow: y, anchorCol: x, focusRow: y, focusCol: x, inMessage: false })
275
+ }
276
+ const handleHintDragMove = (x: number, y: number): void => {
277
+ setSelection(current => current === null || current.inMessage ? current : { ...current, focusRow: y, focusCol: x })
278
+ }
279
+ const handleHintRelease = (_x: number, _y: number, dragged: boolean): void => {
280
+ if (!dragged && hintPendingPick.current !== null) {
281
+ inputRef.current?.hintPick(hintPendingPick.current)
282
+ }
283
+ hintPendingPick.current = null
284
+ }
285
+ const selectionRef = useRef<LineSelection | null>(null)
286
+ const { selection, messageAreaSelection, chromeSelection, setSelection, clearSelection } = useMouseSelection({
287
+ messages: displayMessages,
288
+ columns,
289
+ rows,
290
+ scrollTop,
291
+ messageHeight,
292
+ inputHeight,
293
+ dialogOpen: dialog !== null,
294
+ hint: hintState,
295
+ screen,
296
+ inputHandle: inputRef,
297
+ panelActive: panel !== null,
298
+ panelHandle: panelHandleRef,
299
+ getScroll,
300
+ onHintPress: handleHintPress,
301
+ onHintDragStart: handleHintDragStart,
302
+ onHintDragMove: handleHintDragMove,
303
+ onHintRelease: handleHintRelease,
304
+ onDialogWheel: (y, dir) => dialogRef.current?.wheelAt(y, dir) === true,
305
+ onScroll: applyScroll,
306
+ onToggleMessage: handleToggle,
307
+ onDialogClick: (y, x) => dialogRef.current?.clickAt(y, x),
308
+ })
309
+ selectionRef.current = selection
310
+ useInput((input, key) => {
311
+ if (isKeyConsumed() && !(key.ctrl && input === 'c' && selection !== null)) return
312
+ if (dialog !== null && !selection) return
313
+ if (key.escape) {
314
+ if (hintOpen) return
315
+ if (panel === null && busy && bridge !== undefined) {
316
+ const now = Date.now()
317
+ if (escArmed && now - escAtRef.current <= INTERRUPT_ARM_MS) {
318
+ disarmInterrupt()
319
+ bridge.interrupt()
320
+ } else {
321
+ escAtRef.current = now
322
+ setEscArmed(true)
323
+ if (escTimerRef.current !== null) clearTimeout(escTimerRef.current)
324
+ escTimerRef.current = setTimeout(disarmInterrupt, INTERRUPT_ARM_MS)
325
+ }
326
+ }
327
+ return
328
+ }
329
+ if (key.ctrl && input === 'c') {
330
+ if (selection) {
331
+ const text = copySelection(selection, {
332
+ messageText: sel => selectionText(displayMessages, columns, sel),
333
+ chromeText: sel => chromeSelectionText(sel),
334
+ })
335
+ if (text) writeClipboardText(text)
336
+ clearSelection()
337
+ return
338
+ }
339
+ if (!exiting.current) {
340
+ exiting.current = true
341
+ process.kill(process.pid, 'SIGINT')
342
+ setTimeout(() => process.exit(0), FORCE_EXIT_DELAY_MS).unref()
343
+ }
344
+ }
345
+ })
346
+ return (
347
+ <SpinnerTickProvider busy={busy}>
348
+ <SelectionContext.Provider value={messageAreaSelection}>
349
+ <Box flexDirection="column" width={columns} height={rows}>
350
+ <KeymapGate />
351
+ <MessageList
352
+ messages={displayMessages}
353
+ height={messageHeight}
354
+ width={columns}
355
+ scrollTop={scrollTop}
356
+ onScroll={applyScroll}
357
+ interactive={dialog === null}
358
+ themeTick={themeTick}
359
+ />
360
+ <SelectionContext.Provider value={chromeSelection}>
361
+ {panel === null && (
362
+ <InputBar
363
+ ref={inputRef}
364
+ width={columns}
365
+ columns={columns}
366
+ rows={rows}
367
+ value={value}
368
+ cursor={cursor}
369
+ api={api}
370
+ statusReady={bridge !== undefined}
371
+ modelName={bridge?.modelName() ?? modelName}
372
+ permissionMode={permissionMode}
373
+ effortName={bridge?.effortName()}
374
+ presetName={bridge?.presetName()}
375
+ interactive={composerInteractive}
376
+ hint={hintState}
377
+ />
378
+ )}
379
+ {panel !== null && bridge !== undefined && (() => {
380
+ const contribution = bridge.interactions.panels.of(panel.kind)
381
+ if (contribution === undefined) return null
382
+ const Panel = contribution.component
383
+ const request = panel.kind === 'approval'
384
+ ? { approval: panel.approval }
385
+ : panel.kind === 'question'
386
+ ? { question: panel.question }
387
+ : panel.request
388
+ const key = panel.kind === 'approval'
389
+ ? `approval-${panel.approval?.id ?? ''}`
390
+ : panel.kind === 'question'
391
+ ? `question-${panel.question?.id ?? ''}`
392
+ : panel.kind
393
+ return (
394
+ <Panel
395
+ key={key}
396
+ request={request}
397
+ resolve={value => {
398
+ if (panel.kind === 'approval') panel.settle?.(value)
399
+ else panel.resolve?.(value)
400
+ }}
401
+ reject={cause => {
402
+ if (panel.kind === 'approval') panel.settle?.('cancelled')
403
+ else panel.reject?.(cause)
404
+ }}
405
+ active={dialog === null}
406
+ columns={columns}
407
+ rows={rows}
408
+ innerWidth={contentWidth}
409
+ blockWidth={contentWidth + 4}
410
+ background={permissionChrome.color}
411
+ handleRef={panelHandleRef}
412
+ onResize={setPanelHeight}
413
+ />
414
+ )
415
+ })()}
416
+ {bridge !== undefined && (
417
+ <StatusBar
418
+ bridge={bridge}
419
+ columns={columns}
420
+ top={rows - 1}
421
+ busy={busy}
422
+ running={running}
423
+ todoBadge={todoBadge}
424
+ activity={activity}
425
+ panel={panel}
426
+ retryStatus={retryStatus}
427
+ escArmed={escArmed}
428
+ streamedChars={streamedChars}
429
+ />
430
+ )}
431
+ </SelectionContext.Provider>
432
+ {dialog !== null && (() => {
433
+ const entry = windows.find(candidate => candidate.id === dialog)
434
+ if (entry !== undefined && bridge !== undefined) {
435
+ const Window = entry.component
436
+ return (
437
+ <CloseGuardContext.Provider value={selection !== null}>
438
+ <SelectionContext.Provider value={chromeSelection}>
439
+ <Window open handleRef={dialogRef} onClose={() => overlays.pop()} />
440
+ </SelectionContext.Provider>
441
+ </CloseGuardContext.Provider>
442
+ )
443
+ }
444
+ const overlay = overlayContribution
445
+ if (overlay !== undefined) {
446
+ return <Region>{overlay.render({ onClose: () => overlays.pop() })}</Region>
447
+ }
448
+ return null
449
+ })()}
450
+ </Box>
451
+ </SelectionContext.Provider>
452
+ </SpinnerTickProvider>
453
+ )
454
+ }
@@ -0,0 +1,9 @@
1
+ import { Text } from 'ink'
2
+ import { glyphs } from '../../terminal/glyphs.ts'
3
+
4
+ export function CapText({ background, top, width }: { background: string; top: boolean; width: number }) {
5
+ if (glyphs.halfBlockCaps) {
6
+ return <Text color={background}>{(top ? glyphs.blockCapTop : glyphs.blockCapBottom).repeat(width)}</Text>
7
+ }
8
+ return <Text backgroundColor={background}>{' '.repeat(width)}</Text>
9
+ }
@@ -0,0 +1,87 @@
1
+ import { keyedRegistry } from '../../kernel/registry.ts'
2
+
3
+ export interface HintMatchContext {
4
+ /** The raw composer value; always starts with '/' without whitespace. */
5
+ value: string
6
+ /** Value without the leading slash, lowercased. */
7
+ query: string
8
+ }
9
+
10
+ export interface HintMatcherContribution {
11
+ id: string
12
+ order?: number
13
+ /**
14
+ * Filter and rank command hint entries. Return the ordered list to use;
15
+ * return null to fall through to the next matcher (builtin tiers last).
16
+ */
17
+ match(entries: readonly HintEntry[], context: HintMatchContext): HintEntry[] | null
18
+ }
19
+
20
+ export interface HintEntry {
21
+ command: string
22
+ description: string
23
+ hint?: string
24
+ }
25
+
26
+ export interface HintArgsContribution {
27
+ id: string
28
+ order?: number
29
+ /**
30
+ * Provide literal argument completions for a command name (without slash);
31
+ * return null to fall through to the next provider.
32
+ */
33
+ args(name: string): string[] | null | Promise<string[] | null>
34
+ }
35
+
36
+ const matchers = keyedRegistry<HintMatcherContribution>()
37
+ const argProviders = keyedRegistry<HintArgsContribution>()
38
+
39
+ const BUILTIN_ORDER = 500
40
+
41
+ export function registerHintMatcher(contribution: HintMatcherContribution): () => void {
42
+ return matchers.register(contribution.id, contribution, { order: contribution.order ?? 100 })
43
+ }
44
+
45
+ export function registerHintArgsProvider(contribution: HintArgsContribution): () => void {
46
+ return argProviders.register(contribution.id, contribution, { order: contribution.order ?? 100 })
47
+ }
48
+
49
+ /** First matcher (by order) that returns a list wins; null falls through. */
50
+ export function matchHintEntries(entries: readonly HintEntry[], value: string): HintEntry[] {
51
+ const context: HintMatchContext = { value, query: value.slice(1).toLowerCase() }
52
+ for (const matcher of matchers.values()) {
53
+ const result = matcher.match(entries, context)
54
+ if (result !== null) return result
55
+ }
56
+ return tieredMatch(entries, context.query)
57
+ }
58
+
59
+ /** Builtin three-tier matching: prefix, subsequence, description substring. */
60
+ export function tieredMatch(entries: readonly HintEntry[], query: string): HintEntry[] {
61
+ const tiers: HintEntry[][] = [[], [], []]
62
+ for (const entry of entries) {
63
+ const name = entry.command.slice(1).toLowerCase()
64
+ if (name.startsWith(query)) tiers[0]!.push(entry)
65
+ else if (isSubsequence(query, name)) tiers[1]!.push(entry)
66
+ else if (entry.description.toLowerCase().includes(query)) tiers[2]!.push(entry)
67
+ }
68
+ return [...tiers[0]!, ...tiers[1]!, ...tiers[2]!]
69
+ }
70
+
71
+ function isSubsequence(query: string, name: string): boolean {
72
+ let at = 0
73
+ for (const ch of name) {
74
+ if (ch === query[at]) at += 1
75
+ if (at === query.length) return true
76
+ }
77
+ return query.length === 0
78
+ }
79
+
80
+ /** First provider (by order) that returns a non-null list wins. */
81
+ export async function hintArgsFor(name: string, builtin: (name: string) => string[]): Promise<string[]> {
82
+ for (const provider of argProviders.values()) {
83
+ const result = await provider.args(name)
84
+ if (result !== null) return result
85
+ }
86
+ return builtin(name)
87
+ }
@@ -0,0 +1,27 @@
1
+ import { keyedRegistry } from '../../kernel/registry.ts'
2
+ import type { InputStatusContribution, InputStatusContext } from '../../contract/index.ts'
3
+
4
+ const inputStatus = keyedRegistry<InputStatusContribution>()
5
+
6
+ /**
7
+ * Contributions render segments into the composer status line, between the
8
+ * builtin mode/model/effort parts and the caret nonce. Order controls the
9
+ * sequence; default 100.
10
+ */
11
+ export function registerInputStatus(contribution: InputStatusContribution): () => void {
12
+ return inputStatus.register(contribution.id, contribution, { order: contribution.order })
13
+ }
14
+
15
+ export function inputStatusParts(context: InputStatusContext): Array<{ text: string; color?: string; bold?: boolean }> {
16
+ const parts: Array<{ text: string; color?: string; bold?: boolean }> = []
17
+ for (const contribution of inputStatus.values()) {
18
+ const rendered = contribution.render(context)
19
+ if (rendered === null || rendered === undefined) continue
20
+ const list = Array.isArray(rendered) ? rendered : [rendered]
21
+ for (const part of list) {
22
+ if (part.text === '') continue
23
+ parts.push(part.color === undefined && part.bold === undefined ? { text: part.text } : part)
24
+ }
25
+ }
26
+ return parts
27
+ }
@@ -0,0 +1,29 @@
1
+ import { useInput, useStdin } from 'ink'
2
+ import { useEffect } from 'react'
3
+ import { beginKeyEvent, markKeymapConsumed } from '../key-arbiter.ts'
4
+ import { handleKeyContributions } from '../keymap.ts'
5
+
6
+ /**
7
+ * Earliest key subscriber. Chrome key contributions are evaluated here with
8
+ * true preemption over every later participant (message list, composer,
9
+ * panels, dialog, shell), which all observe the arbiter's consumed flag.
10
+ * A new arbitration generation starts per stdin data chunk so a consumed
11
+ * flag never leaks into the next physical key event, however events are
12
+ * batched by the terminal or the test harness.
13
+ */
14
+ export function KeymapGate(): null {
15
+ const { stdin } = useStdin()
16
+ useEffect(() => {
17
+ const onData = (): void => {
18
+ beginKeyEvent()
19
+ }
20
+ stdin?.on('data', onData)
21
+ return () => {
22
+ stdin?.off('data', onData)
23
+ }
24
+ }, [stdin])
25
+ useInput((input, key) => {
26
+ if (handleKeyContributions(input, key)) markKeymapConsumed()
27
+ })
28
+ return null
29
+ }