@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
@@ -0,0 +1,495 @@
1
+ import { useInput, usePaste } from 'ink'
2
+ import { isKeyConsumed } from '../key-arbiter.ts'
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { isMouseResidue } from '../../terminal/mouse.ts'
5
+ import { colToCharIndex } from '../../core/text.ts'
6
+ import { moveCaretLine } from '../../core/composer-layout.ts'
7
+ import { editBackspace, editCursorLeft, editCursorRight, editCursorWordLeft, editCursorWordRight, editDelete, editDeleteWordLeft, editDeleteWordRight, editInsert } from '../../core/edit.ts'
8
+ import type { EditState } from '../../core/edit.ts'
9
+ import { buildPasteFields, expandComposerValue, insertClipboardImage, insertFieldSpec, reconcileComposerFields } from './composer-fields.ts'
10
+ import type { ComposerFieldMap, ComposerSubmission } from './composer-fields.ts'
11
+ import { sanitizePastedText } from '../../core/paste.ts'
12
+ import { readClipboardImage, readClipboardImageUris, readClipboardText } from '../../terminal/clipboard.ts'
13
+ import { claimPaste } from './composer-paste.ts'
14
+ import { handleComposerKeyBindings } from './composer-keys.ts'
15
+ import { setComposerInserter } from './composer-bus.ts'
16
+ import { COMMANDS, filterHintEntries, literalHintArgs } from './commands.ts'
17
+ import type { CommandHintItem } from './commands.ts'
18
+
19
+ export interface ComposerState {
20
+ value: string
21
+ cursor: number
22
+ hintOpen: boolean
23
+ commandIndex: number
24
+ api: ComposerApi
25
+ }
26
+
27
+ export interface ComposerApi {
28
+ moveLineBy(delta: -1 | 1): void
29
+ placeCursor(charIndex: number): void
30
+ selectHint(absoluteIndex: number): void
31
+ confirmHint(): void
32
+ hintMove(delta: -1 | 1): void
33
+ hintClickAt(absoluteIndex: number): void
34
+ hintPick(absoluteIndex: number): void
35
+ }
36
+
37
+ function opensHint(text: string): boolean {
38
+ return text.startsWith('/') && !/\s/.test(text)
39
+ }
40
+
41
+ const UNDO_LIMIT = 200
42
+
43
+ export function useComposer(
44
+ onSend: (submission: ComposerSubmission) => void,
45
+ interactive: boolean,
46
+ contentWidth: number,
47
+ onCycleMode?: () => void,
48
+ entries?: readonly CommandHintItem[],
49
+ listCommandArgs?: (name: string) => Promise<string[]>,
50
+ ): ComposerState {
51
+ const [value, setValue] = useState('')
52
+ const [cursor, setCursor] = useState(0)
53
+ const [hintOpen, setHintOpen] = useState(false)
54
+ const [commandIndex, setCommandIndex] = useState(0)
55
+ const valueRef = useRef('')
56
+ const cursorRef = useRef(0)
57
+ const hintOpenRef = useRef(false)
58
+ const commandIndexRef = useRef(0)
59
+ const widthRef = useRef(contentWidth)
60
+ const entriesRef = useRef<readonly CommandHintItem[] | undefined>(entries)
61
+ const listCommandArgsRef = useRef(listCommandArgs)
62
+ const interactiveRef = useRef(interactive)
63
+ const fieldsRef = useRef<ComposerFieldMap>(new Map())
64
+ widthRef.current = contentWidth
65
+ entriesRef.current = entries
66
+ listCommandArgsRef.current = listCommandArgs
67
+ interactiveRef.current = interactive
68
+ valueRef.current = value
69
+ cursorRef.current = cursor
70
+ hintOpenRef.current = hintOpen
71
+ commandIndexRef.current = commandIndex
72
+ const visibleFor = (text: string) => filterHintEntries(entriesRef.current ?? COMMANDS, text)
73
+ const undoStackRef = useRef<EditState[]>([])
74
+ const redoStackRef = useRef<EditState[]>([])
75
+ const pushUndoSnapshot = (): void => {
76
+ resetHistoryBrowse()
77
+ undoStackRef.current.push({ value: valueRef.current, cursor: cursorRef.current })
78
+ if (undoStackRef.current.length > UNDO_LIMIT) undoStackRef.current.shift()
79
+ redoStackRef.current.length = 0
80
+ }
81
+ const applySnapshot = (next: EditState): void => {
82
+ valueRef.current = next.value
83
+ cursorRef.current = next.cursor
84
+ setValue(next.value)
85
+ setCursor(next.cursor)
86
+ reconcileComposerFields(next.value, fieldsRef.current)
87
+ }
88
+ const applyEdit = (next: EditState | null): void => {
89
+ if (next === null) return
90
+ pushUndoSnapshot()
91
+ applySnapshot(next)
92
+ }
93
+ const undoEdit = (): void => {
94
+ const snapshot = undoStackRef.current.pop()
95
+ if (snapshot === undefined) return
96
+ redoStackRef.current.push({ value: valueRef.current, cursor: cursorRef.current })
97
+ if (redoStackRef.current.length > UNDO_LIMIT) redoStackRef.current.shift()
98
+ applySnapshot(snapshot)
99
+ refreshHint()
100
+ }
101
+ const redoEdit = (): void => {
102
+ const snapshot = redoStackRef.current.pop()
103
+ if (snapshot === undefined) return
104
+ undoStackRef.current.push({ value: valueRef.current, cursor: cursorRef.current })
105
+ if (undoStackRef.current.length > UNDO_LIMIT) undoStackRef.current.shift()
106
+ applySnapshot(snapshot)
107
+ refreshHint()
108
+ }
109
+ const HISTORY_LIMIT = 50
110
+ const historyRef = useRef<string[]>([])
111
+ const historyIndexRef = useRef(-1)
112
+ const historyDraftRef = useRef<EditState | null>(null)
113
+ const resetHistoryBrowse = (): void => {
114
+ historyIndexRef.current = -1
115
+ historyDraftRef.current = null
116
+ }
117
+ const historyOlder = (): void => {
118
+ const history = historyRef.current
119
+ if (history.length === 0) return
120
+ if (historyIndexRef.current === -1) {
121
+ historyDraftRef.current = { value: valueRef.current, cursor: cursorRef.current }
122
+ historyIndexRef.current = 0
123
+ } else if (historyIndexRef.current >= history.length - 1) {
124
+ return
125
+ } else {
126
+ historyIndexRef.current += 1
127
+ }
128
+ const entry = history[history.length - 1 - historyIndexRef.current]!
129
+ applySnapshot({ value: entry, cursor: entry.length })
130
+ closeHint()
131
+ }
132
+ const historyNewer = (): void => {
133
+ if (historyIndexRef.current === -1) return
134
+ if (historyIndexRef.current === 0) {
135
+ const draft = historyDraftRef.current
136
+ resetHistoryBrowse()
137
+ applySnapshot(draft ?? { value: '', cursor: 0 })
138
+ closeHint()
139
+ return
140
+ }
141
+ historyIndexRef.current -= 1
142
+ const entry = historyRef.current[historyRef.current.length - 1 - historyIndexRef.current]!
143
+ applySnapshot({ value: entry, cursor: entry.length })
144
+ closeHint()
145
+ }
146
+ const recordHistoryEntry = (text: string): void => {
147
+ resetHistoryBrowse()
148
+ if (text === '') return
149
+ const history = historyRef.current
150
+ if (history[history.length - 1] === text) return
151
+ history.push(text)
152
+ if (history.length > HISTORY_LIMIT) history.shift()
153
+ }
154
+ const closeHint = (): void => {
155
+ setHintOpen(false)
156
+ hintOpenRef.current = false
157
+ setCommandIndex(0)
158
+ commandIndexRef.current = 0
159
+ }
160
+ const openHint = (): void => {
161
+ setHintOpen(true)
162
+ hintOpenRef.current = true
163
+ setCommandIndex(0)
164
+ commandIndexRef.current = 0
165
+ }
166
+ const refreshHint = (): void => {
167
+ if (opensHint(valueRef.current)) openHint()
168
+ else closeHint()
169
+ }
170
+ const applyEditAndRefreshHint = (next: { value: string; cursor: number } | null): boolean => {
171
+ if (next === null) return false
172
+ applyEdit(next)
173
+ refreshHint()
174
+ return true
175
+ }
176
+ const apiRef = useRef<ComposerApi>({
177
+ moveLineBy(delta) {
178
+ const next = moveCaretLine(valueRef.current, cursorRef.current, widthRef.current, delta)
179
+ cursorRef.current = next
180
+ setCursor(next)
181
+ },
182
+ placeCursor(charIndex) {
183
+ const clamped = Math.max(0, Math.min(charIndex, valueRef.current.length))
184
+ cursorRef.current = clamped
185
+ setCursor(clamped)
186
+ },
187
+ selectHint(absoluteIndex) {
188
+ const commands = visibleFor(valueRef.current)
189
+ const clamped = Math.max(0, Math.min(absoluteIndex, commands.length - 1))
190
+ commandIndexRef.current = clamped
191
+ setCommandIndex(clamped)
192
+ },
193
+ confirmHint() {
194
+ const commands = visibleFor(valueRef.current)
195
+ if (commands.length === 0) return
196
+ const command = commands[Math.min(commandIndexRef.current, commands.length - 1)]?.command
197
+ if (command === undefined) return
198
+ const completed = `${command} `
199
+ pushUndoSnapshot()
200
+ valueRef.current = completed
201
+ cursorRef.current = completed.length
202
+ setValue(completed)
203
+ setCursor(completed.length)
204
+ closeHint()
205
+ },
206
+ hintMove(delta) {
207
+ const commands = visibleFor(valueRef.current)
208
+ if (commands.length === 0) return
209
+ const next = (commandIndexRef.current + delta + commands.length) % commands.length
210
+ commandIndexRef.current = next
211
+ setCommandIndex(next)
212
+ },
213
+ hintClickAt(absoluteIndex) {
214
+ if (!hintOpenRef.current) return
215
+ if (absoluteIndex === commandIndexRef.current) {
216
+ apiRef.current.confirmHint()
217
+ return
218
+ }
219
+ apiRef.current.selectHint(absoluteIndex)
220
+ },
221
+ hintPick(absoluteIndex) {
222
+ if (!hintOpenRef.current) return
223
+ apiRef.current.selectHint(absoluteIndex)
224
+ apiRef.current.confirmHint()
225
+ },
226
+ })
227
+ const insertPaste = (normalized: string): void => {
228
+ const sanitized = sanitizePastedText(normalized)
229
+ if (sanitized === '') return
230
+ const { value: current, cursor: at } = { value: valueRef.current, cursor: cursorRef.current }
231
+ const inserted = buildPasteFields(sanitized, fieldsRef.current)
232
+ applyEdit(editInsert({ value: current, cursor: at }, inserted))
233
+ refreshHint()
234
+ }
235
+ const upgradeFirstImageGroupFromClipboard = async (): Promise<void> => {
236
+ const entries = [...fieldsRef.current.entries()]
237
+ const imageFields = entries.filter(([, field]) => field.kind === 'image')
238
+ if (imageFields.length !== 1) return
239
+ const [, field] = imageFields[0]!
240
+ const pathCount = (field.images ?? []).filter(image => image.kind === 'path').length
241
+ if (pathCount !== 1) return
242
+ try {
243
+ const uris = await readClipboardImageUris()
244
+ if (uris.length > 1) field.images = uris.map(path => ({ kind: 'path' as const, path }))
245
+ } catch {
246
+ // Clipboard URI lists are best-effort; a single pasted path stays as-is.
247
+ }
248
+ }
249
+ const pasteFromClipboard = async (allowText: boolean): Promise<void> => {
250
+ try {
251
+ const image = await readClipboardImage()
252
+ if (image !== undefined) {
253
+ insertPaste(insertClipboardImage(image, fieldsRef.current))
254
+ return
255
+ }
256
+ if (!allowText) return
257
+ const text = await readClipboardText()
258
+ if (text !== undefined && text !== '') insertPaste(text.replace(/\r\n?/g, '\n'))
259
+ } catch {
260
+ // A clipboard backend failure is ignored: the paste simply does not insert.
261
+ }
262
+ }
263
+ usePaste(text => {
264
+ if (!interactive) return
265
+ const normalized = text.replace(/\r\n?/g, '\n')
266
+ if (normalized === '') {
267
+ void pasteFromClipboard(true)
268
+ return
269
+ }
270
+ void (async () => {
271
+ const claimed = claimPaste(normalized, cursorRef.current)
272
+ insertPaste(claimed === undefined ? normalized : claimed.insert)
273
+ if (claimed === undefined) void upgradeFirstImageGroupFromClipboard()
274
+ })()
275
+ }, { isActive: interactive })
276
+ const insertText = (text: string): void => {
277
+ applyEdit(editInsert({ value: valueRef.current, cursor: cursorRef.current }, text))
278
+ }
279
+ useEffect(() => {
280
+ setComposerInserter(spec => {
281
+ if (!interactiveRef.current) return false
282
+ if (spec.field !== undefined) {
283
+ const char = insertFieldSpec(spec.field, fieldsRef.current)
284
+ applyEdit(editInsert({ value: valueRef.current, cursor: cursorRef.current }, char))
285
+ refreshHint()
286
+ return true
287
+ }
288
+ if (spec.text !== undefined && spec.text !== '') {
289
+ applyEdit(editInsert({ value: valueRef.current, cursor: cursorRef.current }, sanitizePastedText(spec.text)))
290
+ refreshHint()
291
+ return true
292
+ }
293
+ return false
294
+ })
295
+ return () => setComposerInserter(undefined)
296
+ }, [])
297
+ const completeCommandArg = (): void => {
298
+ const v = valueRef.current
299
+ const match = /^(\/\S+)(?:\s+(\S*))?(?:\s+(.*\S))?\s*$/.exec(v)
300
+ if (match === null) return
301
+ const commandToken = match[1]!
302
+ const entry = entriesRef.current?.find(candidate => candidate.command === commandToken)
303
+ if (entry === undefined) return
304
+ const currentArg = match[2] ?? ''
305
+ const rest = match[3]
306
+ void (async () => {
307
+ let candidates: string[] = []
308
+ try {
309
+ candidates = await listCommandArgsRef.current?.(entry.command.slice(1)) ?? []
310
+ } catch {
311
+ // An argument provider failure abandons the completion quietly.
312
+ return
313
+ }
314
+ if (candidates.length === 0) candidates = literalHintArgs(entry.hint)
315
+ if (candidates.length === 0) return
316
+ const exactIndex = candidates.indexOf(currentArg)
317
+ let next: string
318
+ if (exactIndex >= 0) {
319
+ next = candidates[(exactIndex + 1) % candidates.length]!
320
+ } else {
321
+ next = candidates.find(candidate => candidate.startsWith(currentArg)) ?? candidates[0]!
322
+ }
323
+ const completed = `${commandToken} ${next}${rest === undefined ? '' : ` ${rest}`}`
324
+ pushUndoSnapshot()
325
+ valueRef.current = completed
326
+ cursorRef.current = completed.length
327
+ setValue(completed)
328
+ setCursor(completed.length)
329
+ closeHint()
330
+ })()
331
+ }
332
+ useInput((input, key) => {
333
+ if (!interactive || isKeyConsumed()) return
334
+ if (handleComposerKeyBindings(input, key)) {
335
+ return
336
+ }
337
+ if (input === '\n') {
338
+ insertText('\n')
339
+ return
340
+ }
341
+ const v = valueRef.current
342
+ const c = cursorRef.current
343
+ const commands = visibleFor(v)
344
+ const showHint = hintOpenRef.current && commands.length > 0
345
+ if (key.return && !key.shift && showHint) {
346
+ const selected = commands[Math.min(commandIndexRef.current, commands.length - 1)]
347
+ const exactArgless = selected !== undefined && selected.hint === undefined && v === selected.command
348
+ if (!exactArgless) {
349
+ apiRef.current.confirmHint()
350
+ return
351
+ }
352
+ }
353
+ if (key.escape && showHint) {
354
+ closeHint()
355
+ return
356
+ }
357
+ if (key.tab) {
358
+ if (!v.startsWith('/')) {
359
+ onCycleMode?.()
360
+ return
361
+ }
362
+ const lastChar = v.length > 0 ? v[v.length - 1] : undefined
363
+ const completable = c === v.length && lastChar !== undefined && !/\s/.test(lastChar)
364
+ const trailingCommand = c === v.length && /^(\/\S+)\s+$/.test(v)
365
+ const exactCommand = entriesRef.current?.some(entry => entry.command === v) ?? false
366
+ if (completable && exactCommand) {
367
+ completeCommandArg()
368
+ return
369
+ }
370
+ if (showHint) {
371
+ apiRef.current.confirmHint()
372
+ return
373
+ }
374
+ if (completable || trailingCommand) {
375
+ completeCommandArg()
376
+ }
377
+ return
378
+ }
379
+ if (key.upArrow && showHint) {
380
+ const next = (commandIndexRef.current - 1 + commands.length) % commands.length
381
+ commandIndexRef.current = next
382
+ setCommandIndex(next)
383
+ return
384
+ }
385
+ if (key.downArrow && showHint) {
386
+ const next = (commandIndexRef.current + 1) % commands.length
387
+ commandIndexRef.current = next
388
+ setCommandIndex(next)
389
+ return
390
+ }
391
+ if (key.return && !key.shift) {
392
+ const submission = expandComposerValue(v, fieldsRef.current)
393
+ if (submission.text !== '' || submission.images.length > 0) onSend(submission)
394
+ recordHistoryEntry(submission.text)
395
+ fieldsRef.current = new Map()
396
+ valueRef.current = ''
397
+ cursorRef.current = 0
398
+ setValue('')
399
+ setCursor(0)
400
+ undoStackRef.current.length = 0
401
+ redoStackRef.current.length = 0
402
+ closeHint()
403
+ return
404
+ }
405
+ if (key.return) {
406
+ insertText('\n')
407
+ return
408
+ }
409
+ if (key.ctrl && input === 'v') {
410
+ void pasteFromClipboard(true)
411
+ return
412
+ }
413
+ if (key.ctrl && input === 'z') {
414
+ if (key.shift) redoEdit()
415
+ else undoEdit()
416
+ return
417
+ }
418
+ if (key.ctrl && input === 'y') {
419
+ redoEdit()
420
+ return
421
+ }
422
+ if (key.meta && key.backspace && applyEditAndRefreshHint(editDeleteWordLeft({ value: v, cursor: c }))) {
423
+ return
424
+ }
425
+ if (key.meta && key.delete && applyEditAndRefreshHint(editDeleteWordRight({ value: v, cursor: c }))) {
426
+ return
427
+ }
428
+ if (key.meta && key.leftArrow && c > 0) {
429
+ const moved = editCursorWordLeft({ value: v, cursor: c })
430
+ cursorRef.current = moved.cursor
431
+ setCursor(moved.cursor)
432
+ return
433
+ }
434
+ if (key.meta && key.rightArrow && c < v.length) {
435
+ const moved = editCursorWordRight({ value: v, cursor: c })
436
+ cursorRef.current = moved.cursor
437
+ setCursor(moved.cursor)
438
+ return
439
+ }
440
+ if (key.backspace && applyEditAndRefreshHint(editBackspace({ value: v, cursor: c }))) {
441
+ return
442
+ }
443
+ if (key.delete && applyEditAndRefreshHint(editDelete({ value: v, cursor: c }))) {
444
+ return
445
+ }
446
+ if (key.leftArrow && c > 0) {
447
+ const moved = editCursorLeft({ value: v, cursor: c })
448
+ cursorRef.current = moved.cursor
449
+ setCursor(moved.cursor)
450
+ return
451
+ }
452
+ if (key.rightArrow && c < v.length) {
453
+ const moved = editCursorRight({ value: v, cursor: c })
454
+ cursorRef.current = moved.cursor
455
+ setCursor(moved.cursor)
456
+ return
457
+ }
458
+ if (key.upArrow) {
459
+ const moved = moveCaretLine(v, c, contentWidth, -1)
460
+ if (moved !== c) {
461
+ cursorRef.current = moved
462
+ setCursor(moved)
463
+ } else {
464
+ historyOlder()
465
+ }
466
+ return
467
+ }
468
+ if (key.downArrow) {
469
+ const moved = moveCaretLine(v, c, contentWidth, 1)
470
+ if (moved !== c) {
471
+ cursorRef.current = moved
472
+ setCursor(moved)
473
+ } else {
474
+ historyNewer()
475
+ }
476
+ return
477
+ }
478
+ if (key.home) {
479
+ cursorRef.current = 0
480
+ setCursor(0)
481
+ return
482
+ }
483
+ if (key.end) {
484
+ cursorRef.current = v.length
485
+ setCursor(v.length)
486
+ return
487
+ }
488
+ if (input && !key.ctrl && !key.meta && !isMouseResidue(input)) {
489
+ if (/[\u0000-\u001f\u007f]/.test(input)) return
490
+ applyEditAndRefreshHint(editInsert({ value: v, cursor: c }, input))
491
+ return
492
+ }
493
+ })
494
+ return { value, cursor, hintOpen, commandIndex, api: apiRef.current }
495
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Key-event suppression for ink's broadcast-based useInput.
3
+ *
4
+ * ink delivers every stdin chunk to every useInput subscriber, and delivery
5
+ * order follows subscription order: KeymapGate (chrome key contributions,
6
+ * mounted as the first child) -> message list -> composer -> panels ->
7
+ * dialog -> the App shell. A participant that must win over the later ones
8
+ * marks the current event consumed; later participants skip it.
9
+ *
10
+ * The consumed flag is scoped to one stdin chunk. KeymapGate bumps the
11
+ * generation from its own stdin data listener, so even several synchronous
12
+ * writes (as tests do) each get a fresh generation and no flag leaks across
13
+ * events.
14
+ */
15
+ let generation = 0
16
+ let consumedAtGeneration = -1
17
+
18
+ /** Start a new key-event generation; called once per stdin chunk. */
19
+ export function beginKeyEvent(): void {
20
+ generation += 1
21
+ }
22
+
23
+ /** Chrome key contributions consumed the event; later participants must skip it. */
24
+ export function markKeymapConsumed(): void {
25
+ consumedAtGeneration = generation
26
+ }
27
+
28
+ /** True when the chrome key chain consumed the current key event. */
29
+ export function isKeyConsumed(): boolean {
30
+ return consumedAtGeneration === generation
31
+ }
32
+
33
+ /** Test helper. */
34
+ export function resetKeyEvents(): void {
35
+ generation = 0
36
+ consumedAtGeneration = -1
37
+ }
@@ -0,0 +1,15 @@
1
+ import { keyedRegistry } from '../kernel/registry.ts'
2
+ import type { KeyBindingContribution, TuiKey } from '../contract/index.ts'
3
+
4
+ const bindings = keyedRegistry<KeyBindingContribution>()
5
+
6
+ export function registerKeyBinding(contribution: KeyBindingContribution): () => void {
7
+ return bindings.register(contribution.id, contribution, { order: contribution.order })
8
+ }
9
+
10
+ export function handleKeyContributions(input: string, key: TuiKey): boolean {
11
+ for (const contribution of bindings.values()) {
12
+ if (contribution.handle(input, key) === true) return true
13
+ }
14
+ return false
15
+ }
@@ -0,0 +1,77 @@
1
+ import { CHROME_FRAME_ROWS, MESSAGE_INPUT_GAP_ROWS, SCROLLBAR_COL_FROM_EDGE, SCROLLBAR_GAP_COLS, CHROME_MARGIN_X, hintBlockTop } from '../core/metrics.ts'
2
+ import { HINT_INPUT_GAP_ROWS } from '../core/metrics.ts'
3
+
4
+ /**
5
+ * Single source of truth for the shell's derived geometry. Every consumer
6
+ * (App layout, pointer handlers, panels, message scrollbar) derives its
7
+ * region from these helpers instead of repeating the formulas, so changing
8
+ * the chrome skeleton only touches this file.
9
+ */
10
+
11
+ export interface ShellGeometry {
12
+ columns: number
13
+ rows: number
14
+ /** Full height of the input block (frame rows + content rows). */
15
+ inputHeight: number
16
+ /** Height of the message area. */
17
+ messageHeight: number
18
+ /** Height of the active interaction panel body, when a panel owns the bottom. */
19
+ panelHeight: number | null
20
+ }
21
+
22
+ export function inputContentTop(rows: number, inputHeight: number): number {
23
+ return rows - inputHeight
24
+ }
25
+
26
+ /** Inclusive bottom row of the input's editable content area. */
27
+ export function inputContentBottom(rows: number, inputHeight: number): number {
28
+ return inputContentTop(rows, inputHeight) + inputHeight - CHROME_FRAME_ROWS
29
+ }
30
+
31
+ export function inputContentContains(y: number, rows: number, inputHeight: number): boolean {
32
+ return y >= inputContentTop(rows, inputHeight) && y <= inputContentBottom(rows, inputHeight)
33
+ }
34
+
35
+ export function messageHeightOf(rows: number, inputHeight: number): number {
36
+ return Math.max(1, rows - inputHeight - MESSAGE_INPUT_GAP_ROWS)
37
+ }
38
+
39
+ /** Inclusive row span of the active interaction panel (message bottom .. above the status line). */
40
+ export function panelSpan(geometry: ShellGeometry): { top: number; bottom: number } {
41
+ return { top: geometry.messageHeight, bottom: geometry.rows - 2 }
42
+ }
43
+
44
+ export function panelContains(y: number, geometry: ShellGeometry): boolean {
45
+ const span = panelSpan(geometry)
46
+ return y >= span.top && y <= span.bottom
47
+ }
48
+
49
+ /** Anchor row where a panel of `bodyRows` lines must start to sit above the status line. */
50
+ export function panelAnchorRow(rows: number, bodyRows: number): number {
51
+ return rows - 2 - bodyRows
52
+ }
53
+
54
+ export interface HintSpan {
55
+ top: number
56
+ bottom: number
57
+ }
58
+
59
+ export function hintSpan(rows: number, inputHeight: number, visibleCount: number, dialogOpen: boolean): HintSpan | null {
60
+ if (dialogOpen || visibleCount <= 0) return null
61
+ const top = hintBlockTop(rows, inputHeight, visibleCount)
62
+ const bottom = rows - inputHeight - MESSAGE_INPUT_GAP_ROWS - HINT_INPUT_GAP_ROWS
63
+ return { top, bottom }
64
+ }
65
+
66
+ export function hintContains(y: number, rows: number, inputHeight: number, visibleCount: number, dialogOpen: boolean): boolean {
67
+ const span = hintSpan(rows, inputHeight, visibleCount, dialogOpen)
68
+ return span !== null && y >= span.top && y <= span.bottom
69
+ }
70
+
71
+ export function scrollbarColumn(columns: number): number {
72
+ return columns - SCROLLBAR_COL_FROM_EDGE
73
+ }
74
+
75
+ export function messageBackgroundWidth(width: number): number {
76
+ return width - CHROME_MARGIN_X - SCROLLBAR_COL_FROM_EDGE - SCROLLBAR_GAP_COLS
77
+ }