@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,352 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { pointerHandlerEntries, type PointerSession } from '../pointer/registry.ts'
3
+ import type { PointerHandlerContribution } from '../../contract/index.ts'
4
+ import { createBuiltinPointerHandler, type BuiltinPointerHandler } from '../pointer/builtins.ts'
5
+ import type { RefObject } from 'react'
6
+ import type { ScreenCapture } from '../../terminal/screen.ts'
7
+ import { createMouseController } from '../../terminal/mouse.ts'
8
+ import { inputContentContains, hintSpan, scrollbarColumn } from '../layout-service.ts'
9
+ import { clampFocusRow, toScreenSelection } from '../../model/selection.ts'
10
+ import type { LineSelection } from '../../model/selection.ts'
11
+ import type { ScrollSnapshot } from './use-scroll.ts'
12
+ import { rowInfoAt, rowCount, scrollbarGeometry } from '../message/layout.ts'
13
+ import { POINTER_BUILTIN_ORDER } from '../pointer/registry.ts'
14
+ import type { InputBarHandle } from '../input/input-bar.tsx'
15
+ import type { PanelPointerHandle } from '../panels/approval-panel.tsx'
16
+ import type { CommandHintState } from '../input/commands.ts'
17
+ import type { Message } from '../../model/message.ts'
18
+
19
+ const DRAG_SCROLL_INTERVAL_MS = 15
20
+
21
+ export interface HintRegion {
22
+ top: number
23
+ bottom: number
24
+ }
25
+
26
+ export function hintRegion(rows: number, hint: CommandHintState | null, dialogOpen: boolean, inputHeight: number): HintRegion | null {
27
+ return hintSpan(rows, inputHeight, hint?.commands.length ?? 0, dialogOpen)
28
+ }
29
+
30
+ export interface MouseSelectionOptions {
31
+ messages: Message[]
32
+ columns: number
33
+ rows: number
34
+ scrollTop: number
35
+ messageHeight: number
36
+ inputHeight: number
37
+ dialogOpen: boolean
38
+ hint: CommandHintState | null
39
+ screen?: ScreenCapture
40
+ inputHandle?: RefObject<InputBarHandle | null>
41
+ panelActive?: boolean
42
+ panelHandle?: RefObject<PanelPointerHandle | null>
43
+ getScroll(): ScrollSnapshot
44
+ onHintPress?(x: number, y: number): void
45
+ onHintDragStart?(x: number, y: number): void
46
+ onHintDragMove?(x: number, y: number): void
47
+ onHintRelease?(x: number, y: number, dragged: boolean): void
48
+ onDialogWheel?(y: number, dir: -1 | 1): boolean
49
+ onScroll(next: number): void
50
+ onToggleMessage(id: string): void
51
+ onDialogClick(y: number, x: number): void
52
+ }
53
+
54
+ export interface MouseSelectionState {
55
+ selection: LineSelection | null
56
+ messageAreaSelection: LineSelection | null
57
+ chromeSelection: LineSelection | null
58
+ setSelection(next: LineSelection | null | ((current: LineSelection | null) => LineSelection | null)): void
59
+ clearSelection(): void
60
+ }
61
+
62
+ export function useMouseSelection(options: MouseSelectionOptions): MouseSelectionState {
63
+ const { messages, columns, rows, scrollTop, messageHeight, inputHeight, dialogOpen, hint, screen, inputHandle, panelActive = false, panelHandle, getScroll, onHintPress, onHintDragStart, onHintDragMove, onHintRelease, onDialogWheel, onScroll, onToggleMessage, onDialogClick } = options
64
+ const [selection, setSelection] = useState<LineSelection | null>(null)
65
+ const messagesRef = useRef(messages)
66
+ const widthRef = useRef(columns)
67
+ const rowsRef = useRef(rows)
68
+ const scrollTopRef = useRef(scrollTop)
69
+ const messageHeightRef = useRef(messageHeight)
70
+ const inputHeightRef = useRef(inputHeight)
71
+ const dialogOpenRef = useRef(dialogOpen)
72
+ const hintStateRef = useRef<CommandHintState | null>(null)
73
+ const screenRef = useRef<ScreenCapture | undefined>(screen)
74
+ const inputHandleRef = useRef(inputHandle)
75
+ const panelActiveRef = useRef(panelActive)
76
+ const panelHandleRef = useRef(panelHandle)
77
+ const getScrollRef = useRef(getScroll)
78
+ const onHintPressRef = useRef(onHintPress)
79
+ const onHintDragStartRef = useRef(onHintDragStart)
80
+ const onHintDragMoveRef = useRef(onHintDragMove)
81
+ const onHintReleaseRef = useRef(onHintRelease)
82
+ const onDialogWheelRef = useRef(onDialogWheel)
83
+ const clickCandidateRef = useRef<{ messageId: string; y: number; x: number; moved: boolean } | null>(null)
84
+ const dialogClickCandidateRef = useRef<{ x: number; y: number } | null>(null)
85
+ const panelClickCandidateRef = useRef<{ x: number; y: number } | null>(null)
86
+ const inputClickCandidateRef = useRef<{ x: number; y: number } | null>(null)
87
+ const onScrollRef = useRef(onScroll)
88
+ const onToggleMessageRef = useRef(onToggleMessage)
89
+ const onDialogClickRef = useRef(onDialogClick)
90
+ const selectionRef = useRef<LineSelection | null>(null)
91
+ const autoScrollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
92
+ const autoScrollDirRef = useRef<-1 | 1>(1)
93
+ const pointerSessionRef = useRef({ inMessageArea: false })
94
+ const scrollbarSessionRef = useRef<{ grabOffset: number } | null>(null)
95
+ messagesRef.current = messages
96
+ widthRef.current = columns
97
+ rowsRef.current = rows
98
+ scrollTopRef.current = scrollTop
99
+ messageHeightRef.current = messageHeight
100
+ inputHeightRef.current = inputHeight
101
+ dialogOpenRef.current = dialogOpen
102
+ hintStateRef.current = hint
103
+ screenRef.current = screen
104
+ inputHandleRef.current = inputHandle
105
+ panelActiveRef.current = panelActive
106
+ panelHandleRef.current = panelHandle
107
+ getScrollRef.current = getScroll
108
+ onHintPressRef.current = onHintPress
109
+ onHintDragStartRef.current = onHintDragStart
110
+ onHintDragMoveRef.current = onHintDragMove
111
+ onHintReleaseRef.current = onHintRelease
112
+ onDialogWheelRef.current = onDialogWheel
113
+ onScrollRef.current = onScroll
114
+ onToggleMessageRef.current = onToggleMessage
115
+ onDialogClickRef.current = onDialogClick
116
+ selectionRef.current = selection
117
+ useEffect(() => {
118
+ const stopDragScroll = (): void => {
119
+ if (autoScrollTimerRef.current !== null) {
120
+ clearInterval(autoScrollTimerRef.current)
121
+ autoScrollTimerRef.current = null
122
+ }
123
+ }
124
+ const extendSelectionTo = (row: number): void => {
125
+ setSelection(current => current === null || !current.inMessage
126
+ ? current
127
+ : { ...current, focusRow: Math.max(0, row) })
128
+ }
129
+ const startDragScroll = (dir: -1 | 1): void => {
130
+ if (autoScrollTimerRef.current !== null && autoScrollDirRef.current === dir) return
131
+ stopDragScroll()
132
+ autoScrollDirRef.current = dir
133
+ autoScrollTimerRef.current = setInterval(() => {
134
+ const scrollTop = scrollTopRef.current
135
+ const totalRows = rowCount(messagesRef.current, widthRef.current)
136
+ const maxScroll = Math.max(0, totalRows - messageHeightRef.current)
137
+ if ((dir === -1 && scrollTop <= 0) || (dir === 1 && scrollTop >= maxScroll)) {
138
+ stopDragScroll()
139
+ return
140
+ }
141
+ const next = scrollTop + dir
142
+ onScrollRef.current(next)
143
+ extendSelectionTo(dir === 1 ? next + messageHeightRef.current - 1 : next)
144
+ }, DRAG_SCROLL_INTERVAL_MS)
145
+ }
146
+ const updateDragScroll = (eventY: number): void => {
147
+ if (!pointerSessionRef.current.inMessageArea || dialogOpenRef.current) {
148
+ stopDragScroll()
149
+ return
150
+ }
151
+ let dir: -1 | 1 | 0 = 0
152
+ if (eventY <= 0) dir = -1
153
+ else if (eventY >= messageHeightRef.current - 1) dir = 1
154
+ if (dir === 0) stopDragScroll()
155
+ else startDragScroll(dir)
156
+ }
157
+ const inInputContent = (y: number): boolean =>
158
+ inputContentContains(y, rowsRef.current, inputHeightRef.current)
159
+ const scrollbarInfo = (): { geom: { top: number; height: number }; maxScroll: number; travel: number } | null => {
160
+ if (dialogOpenRef.current) return null
161
+ const mh = messageHeightRef.current
162
+ if (mh < 1) return null
163
+ const totalRows = rowCount(messagesRef.current, widthRef.current)
164
+ if (totalRows <= mh) return null
165
+ const geom = scrollbarGeometry(totalRows, mh, scrollTopRef.current)
166
+ if (geom === null) return null
167
+ return { geom, maxScroll: totalRows - mh, travel: Math.max(1, mh - geom.height) }
168
+ }
169
+ const onScrollbarDown = (x: number, y: number): boolean => {
170
+ if (x !== scrollbarColumn(widthRef.current) || y >= messageHeightRef.current) return false
171
+ const info = scrollbarInfo()
172
+ if (info === null) return false
173
+ stopDragScroll()
174
+ setSelection(null)
175
+ let grabOffset: number
176
+ if (y >= info.geom.top && y < info.geom.top + info.geom.height) {
177
+ grabOffset = y - info.geom.top
178
+ } else {
179
+ const centered = Math.max(0, Math.min(info.maxScroll,
180
+ Math.round(((y - info.geom.height / 2) / info.travel) * info.maxScroll)))
181
+ onScrollRef.current(centered)
182
+ grabOffset = y - Math.round((info.travel * centered) / info.maxScroll)
183
+ }
184
+ scrollbarSessionRef.current = { grabOffset }
185
+ pointerSessionRef.current = { inMessageArea: false }
186
+ return true
187
+ }
188
+ const onScrollbarDrag = (y: number): void => {
189
+ const session = scrollbarSessionRef.current
190
+ if (session === null) return
191
+ const info = scrollbarInfo()
192
+ if (info === null) {
193
+ scrollbarSessionRef.current = null
194
+ return
195
+ }
196
+ const thumbTop = Math.max(0, Math.min(info.travel, y - session.grabOffset))
197
+ const target = Math.max(0, Math.min(info.maxScroll, Math.round((thumbTop / info.travel) * info.maxScroll)))
198
+ onScrollRef.current(target)
199
+ }
200
+ const inMessageArea = (screenRow: number): boolean => {
201
+ if (screenRow >= messageHeightRef.current) return false
202
+ if (dialogOpenRef.current) return false
203
+ const region = hintRegion(rowsRef.current, hintStateRef.current, dialogOpenRef.current, inputHeightRef.current)
204
+ if (region !== null && screenRow >= region.top) return false
205
+ return true
206
+ }
207
+ const toContentRow = (screenRow: number) =>
208
+ inMessageArea(screenRow) ? screenRow + scrollTopRef.current : screenRow
209
+ const activeHintRegion = () => hintRegion(rowsRef.current, hintStateRef.current, dialogOpenRef.current, inputHeightRef.current)
210
+ const focusRowFor = (current: LineSelection, eventY: number): number => {
211
+ if (!current.inMessage) {
212
+ const region = activeHintRegion()
213
+ if (region !== null && current.anchorRow >= region.top && current.anchorRow <= region.bottom) {
214
+ return Math.max(region.top, Math.min(eventY, region.bottom))
215
+ }
216
+ }
217
+ return clampFocusRow(current.inMessage, eventY, scrollTopRef.current, messageHeightRef.current, rowsRef.current, dialogOpenRef.current)
218
+ }
219
+ const uiContext = () => ({
220
+ dialogOpen: dialogOpenRef.current,
221
+ panelActive: panelActiveRef.current,
222
+ hintRegion: activeHintRegion(),
223
+ rows: rowsRef.current,
224
+ columns: widthRef.current,
225
+ messageHeight: messageHeightRef.current,
226
+ inputHeight: inputHeightRef.current,
227
+ scrollTop: scrollTopRef.current,
228
+ getScroll: getScrollRef.current,
229
+ })
230
+ const session: PointerSession = {
231
+ getSelection: () => selectionRef.current,
232
+ setSelection: next => setSelection(next),
233
+ scroll: next => onScrollRef.current(next),
234
+ autoScroll: direction => {
235
+ if (direction === 0) stopDragScroll()
236
+ else startDragScroll(direction)
237
+ },
238
+ }
239
+ const builtinDeps = {
240
+ inInputContent,
241
+ inMessageArea,
242
+ toContentRow,
243
+ activeHintRegion,
244
+ focusRowFor,
245
+ clampMessageFocus: (anchorInMessage: boolean, eventY: number) =>
246
+ anchorInMessage
247
+ ? eventY + scrollTopRef.current
248
+ : clampFocusRow(false, eventY, scrollTopRef.current, messageHeightRef.current, rowsRef.current, dialogOpenRef.current),
249
+ setPointerArea: (value: boolean) => {
250
+ pointerSessionRef.current = { inMessageArea: value }
251
+ },
252
+ onScrollbarDown,
253
+ onScrollbarDrag,
254
+ stopDragScroll,
255
+ updateDragScroll,
256
+ setSelection,
257
+ getSelection: () => selectionRef.current,
258
+ rowHasText: (y: number) => screenRef.current?.rowHasText(y) ?? false,
259
+ rowInfoAt: (y: number) => {
260
+ const contentRow = toContentRow(y)
261
+ return rowInfoAt(messagesRef.current, widthRef.current, contentRow)
262
+ },
263
+ inputClickAt: (y: number, x: number) => inputHandleRef.current?.current?.clickAt(y, x),
264
+ panelClickAt: (y: number, x: number) => panelHandleRef.current?.current?.clickAt(y, x),
265
+ dialogWheel: (y: number, dir: -1 | 1) => onDialogWheelRef.current?.(y, dir) === true,
266
+ panelWheel: (dir: -1 | 1) => panelHandleRef.current?.current?.wheel(dir) === true,
267
+ hintWheel: (dir: -1 | 1) => inputHandleRef.current?.current?.hintWheel(dir),
268
+ inputWheel: (dir: -1 | 1) => inputHandleRef.current?.current?.wheel(dir),
269
+ hintPress: (x: number, y: number) => onHintPressRef.current?.(x, y),
270
+ hintDragStart: (x: number, y: number) => onHintDragStartRef.current?.(x, y),
271
+ hintDragMove: (x: number, y: number) => onHintDragMoveRef.current?.(x, y),
272
+ hintRelease: (x: number, y: number, dragged: boolean) => onHintReleaseRef.current?.(x, y, dragged),
273
+ onScroll: (next: number) => onScrollRef.current(next),
274
+ toggleMessage: (id: string) => onToggleMessageRef.current(id),
275
+ dialogClick: (y: number, x: number) => onDialogClickRef.current(y, x),
276
+ }
277
+ const builtinHandler: BuiltinPointerHandler = createBuiltinPointerHandler(builtinDeps)
278
+ let ownerId: string | null = null
279
+ const controller = createMouseController(rawEvent => {
280
+ const frame = { event: rawEvent, session, ui: uiContext() }
281
+ // The builtin composite participates in the same dispatch chain as
282
+ // plugin handlers, sorted by order (builtin at 100, plugin default 300).
283
+ const entries = [
284
+ { key: builtinHandler.id, order: POINTER_BUILTIN_ORDER, value: builtinHandler as PointerHandlerContribution },
285
+ ...pointerHandlerEntries(),
286
+ ].sort((a, b) => a.order - b.order)
287
+ const entryFor = (id: string | null) => entries.find(entry => entry.key === id)
288
+ switch (rawEvent.type) {
289
+ case 'down': {
290
+ if (rawEvent.button !== 0) return
291
+ builtinHandler.beginDown()
292
+ ownerId = null
293
+ for (const entry of entries) {
294
+ if (entry.value.onDown(frame) === true) {
295
+ ownerId = entry.key
296
+ return
297
+ }
298
+ }
299
+ // Dead click on unclaimed space (blank rows, padding): drop any
300
+ // lingering selection, matching native text-editor behavior.
301
+ setSelection(null)
302
+ return
303
+ }
304
+ case 'drag': {
305
+ entryFor(ownerId)?.value.onDrag?.(frame)
306
+ return
307
+ }
308
+ case 'up': {
309
+ stopDragScroll()
310
+ pointerSessionRef.current = { inMessageArea: false }
311
+ entryFor(ownerId)?.value.onUp?.(frame)
312
+ ownerId = null
313
+ return
314
+ }
315
+ case 'scroll': {
316
+ for (const entry of entries) {
317
+ if (entry.value.onWheel?.(frame) === true) return
318
+ }
319
+ return
320
+ }
321
+ default:
322
+ return
323
+ }
324
+ })
325
+ controller.enable()
326
+ return () => {
327
+ controller.disable()
328
+ stopDragScroll()
329
+ }
330
+ }, [])
331
+ const screenSelection = useMemo(
332
+ () => toScreenSelection(selection, scrollTop, messageHeight),
333
+ [selection, scrollTop, messageHeight],
334
+ )
335
+ const messageAreaSelection = useMemo(
336
+ () => (selection !== null && selection.inMessage ? screenSelection : null),
337
+ [selection, screenSelection],
338
+ )
339
+ const chromeSelection = useMemo(
340
+ () => (selection !== null && !selection.inMessage ? screenSelection : null),
341
+ [selection, screenSelection],
342
+ )
343
+ return {
344
+ selection,
345
+ messageAreaSelection,
346
+ chromeSelection,
347
+ setSelection,
348
+ clearSelection() {
349
+ setSelection(null)
350
+ },
351
+ }
352
+ }
@@ -0,0 +1,49 @@
1
+ import { useCallback, useReducer, useRef } from 'react'
2
+
3
+ export interface ScrollSnapshot {
4
+ top: number
5
+ maxScroll: number
6
+ }
7
+
8
+ export interface ScrollState {
9
+ scrollTop: number
10
+ getScroll(): ScrollSnapshot
11
+ applyScroll(next: number): void
12
+ }
13
+
14
+ /**
15
+ * The effective scroll offset is derived during render: when pinned to the
16
+ * bottom the offset is simply maxScroll, so incoming messages shift the view
17
+ * without a second render pass (the previous layout-effect bump rendered the
18
+ * whole App twice per streaming frame). Only explicit scroll commands commit
19
+ * and re-render.
20
+ */
21
+ export function useScroll(total: number, messageHeight: number): ScrollState {
22
+ const maxScroll = Math.max(0, total - messageHeight)
23
+ const [, bump] = useReducer((count: number) => count + 1, 0)
24
+ const scrollTopRef = useRef(0)
25
+ const stickToBottomRef = useRef(true)
26
+ const maxScrollRef = useRef(maxScroll)
27
+ maxScrollRef.current = maxScroll
28
+ if (stickToBottomRef.current) {
29
+ scrollTopRef.current = maxScroll
30
+ } else if (scrollTopRef.current > maxScroll) {
31
+ scrollTopRef.current = maxScroll
32
+ stickToBottomRef.current = scrollTopRef.current >= maxScroll
33
+ }
34
+
35
+ const commit = useCallback((next: number, rerender: boolean): void => {
36
+ const clamped = Math.max(0, Math.min(maxScrollRef.current, next))
37
+ scrollTopRef.current = clamped
38
+ stickToBottomRef.current = clamped >= maxScrollRef.current
39
+ if (rerender) bump()
40
+ }, [bump])
41
+
42
+ const applyScroll = useCallback((next: number): void => {
43
+ commit(next === Infinity ? maxScrollRef.current : next, true)
44
+ }, [commit])
45
+
46
+ const getScroll = useCallback((): ScrollSnapshot => ({ top: scrollTopRef.current, maxScroll: maxScrollRef.current }), [])
47
+
48
+ return { scrollTop: scrollTopRef.current, getScroll, applyScroll }
49
+ }
@@ -0,0 +1,20 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useStdout } from 'ink'
3
+
4
+ export function useTerminalSize(): { columns: number; rows: number } {
5
+ const { stdout } = useStdout()
6
+ const [columns, setColumns] = useState(stdout?.columns ?? 80)
7
+ const [rows, setRows] = useState(stdout?.rows ?? 24)
8
+ useEffect(() => {
9
+ if (!stdout) return
10
+ const onResize = () => {
11
+ setColumns(stdout.columns)
12
+ setRows(stdout.rows)
13
+ }
14
+ stdout.on('resize', onResize)
15
+ return () => {
16
+ stdout.off('resize', onResize)
17
+ }
18
+ }, [stdout])
19
+ return { columns, rows }
20
+ }
@@ -0,0 +1,118 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { keyedRegistry } from '../../kernel/registry.ts'
3
+ import { matchHintEntries } from '../chrome/hint-service.ts'
4
+ import type { CommandDef } from '../../contract/index.ts'
5
+
6
+ export type { CommandDef } from '../../contract/index.ts'
7
+
8
+ export type CommandId = string
9
+
10
+ export interface CommandHintItem {
11
+ command: string
12
+ description: string
13
+ hint?: string
14
+ }
15
+
16
+ const registry = keyedRegistry<CommandDef>()
17
+
18
+ export const COMMANDS: CommandDef[] = []
19
+
20
+ let version = 0
21
+
22
+ function refresh(): void {
23
+ version += 1
24
+ COMMANDS.length = 0
25
+ COMMANDS.push(...registry.values())
26
+ }
27
+
28
+ registry.register('new', { id: 'new', command: '/new', description: 'Start a new conversation in current directory' })
29
+ registry.register('todo', { id: 'todo', command: '/todo', description: 'Show the current task list' })
30
+ refresh()
31
+ registry.subscribe(refresh)
32
+
33
+ export function registerCommand(def: CommandDef): () => void {
34
+ return registry.register(def.id, def, { order: def.order })
35
+ }
36
+
37
+ export function subscribeCommands(listener: () => void): () => void {
38
+ return registry.subscribe(listener)
39
+ }
40
+
41
+ export function useCommandVersion(): number {
42
+ return useSyncExternalStore(subscribeCommands, () => version, () => version)
43
+ }
44
+
45
+ export function matchCommand(text: string): CommandDef | undefined {
46
+ return registry.values().find(command => command.command === text)
47
+ }
48
+
49
+ export type CommandAvailability = (command: CommandDef) => boolean
50
+
51
+ export function matchAvailableCommand(text: string, isAvailable?: CommandAvailability, extra?: readonly CommandDef[]): CommandDef | undefined {
52
+ const token = text.split(/\s+/, 1)[0] ?? ''
53
+ const command = [...registry.values(), ...extra ?? []].find(entry => entry.command === token)
54
+ if (command === undefined) return undefined
55
+ if (isAvailable !== undefined && !isAvailable(command)) return undefined
56
+ return command
57
+ }
58
+
59
+ export function commandArgHints(name: string): readonly string[] | undefined {
60
+ const def = registry.values().find(candidate => candidate.command === `/${name}`)
61
+ return def?.args
62
+ }
63
+
64
+ export function mergeCommandEntries(
65
+ local: CommandDef[],
66
+ remote: readonly { name: string; description: string; hint?: string }[],
67
+ ): CommandHintItem[] {
68
+ const taken = new Set(local.map(command => command.command))
69
+ const entries: CommandHintItem[] = local.map(({ command, description, hint }) => ({
70
+ command,
71
+ description,
72
+ ...(hint === undefined ? {} : { hint }),
73
+ }))
74
+ for (const entry of remote) {
75
+ const command = `/${entry.name}`
76
+ if (taken.has(command)) continue
77
+ taken.add(command)
78
+ entries.push({
79
+ command,
80
+ description: entry.description,
81
+ ...(entry.hint === undefined ? {} : { hint: entry.hint }),
82
+ })
83
+ }
84
+ return entries
85
+ }
86
+
87
+ function isSubsequence(query: string, text: string): boolean {
88
+ let at = 0
89
+ for (let index = 0; index < text.length && at < query.length; index++) {
90
+ if (text[index] === query[at]) at += 1
91
+ }
92
+ return at === query.length
93
+ }
94
+
95
+ export function filterHintEntries(entries: readonly CommandHintItem[], value: string): CommandHintItem[] {
96
+ if (!value.startsWith('/') || /\s/.test(value)) return []
97
+ return matchHintEntries(entries, value)
98
+ }
99
+
100
+ /** Static literal arguments for registry commands whose hints mix literals with free-form prose. */
101
+ export const KNOWN_COMMAND_ARGS: Record<string, readonly string[]> = {
102
+ plan: ['off'],
103
+ }
104
+
105
+ export function literalHintArgs(hint: string | undefined): string[] {
106
+ if (hint === undefined || hint === '') return []
107
+ const body = hint.replace(/^\[/, '').replace(/\]$/, '')
108
+ return body
109
+ .split('|')
110
+ .map(part => part.trim())
111
+ .filter(part => part !== '' && !part.includes('<'))
112
+ }
113
+
114
+ export interface CommandHintState {
115
+ commands: CommandHintItem[]
116
+ selectedIndex: number
117
+ startIndex?: number
118
+ }
@@ -0,0 +1,14 @@
1
+ import type { ComposerInsertSpec } from '../../contract/index.ts'
2
+
3
+ type ComposerInserter = (spec: ComposerInsertSpec) => boolean
4
+
5
+ let inserter: ComposerInserter | undefined
6
+
7
+ export function setComposerInserter(next: ComposerInserter | undefined): void {
8
+ inserter = next
9
+ }
10
+
11
+ export function insertIntoComposer(spec: ComposerInsertSpec): boolean {
12
+ if (inserter === undefined) return false
13
+ return inserter(spec)
14
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ allocateField,
3
+ charactersChipLabel,
4
+ fieldExpandOf,
5
+ imageChipLabel,
6
+ isFieldChar,
7
+ linesChipLabel,
8
+ releaseUnreferenced,
9
+ } from '../../core/fields.ts'
10
+ import { classifyPaste, summarizePaste } from '../../core/paste.ts'
11
+ import type { PendingImage } from '../../core/paste.ts'
12
+ import type { ClipboardImage } from '../../terminal/clipboard.ts'
13
+
14
+ export interface ComposerField {
15
+ kind: string
16
+ label: string
17
+ images?: PendingImage[]
18
+ text?: string
19
+ data?: unknown
20
+ }
21
+
22
+ export type ComposerFieldMap = Map<string, ComposerField>
23
+
24
+ export function buildPasteFields(normalized: string, fields: ComposerFieldMap): string {
25
+ const segments = classifyPaste(normalized)
26
+ let value = ''
27
+ for (const segment of segments) {
28
+ if (segment.type === 'images') {
29
+ value += insertImageField(segment.paths.map(path => ({ kind: 'path' as const, path })), fields)
30
+ continue
31
+ }
32
+ const summary = summarizePaste(segment.text)
33
+ if (summary === null) {
34
+ value += segment.text
35
+ continue
36
+ }
37
+ const label = summary.lines > 1 ? linesChipLabel(summary.lines) : charactersChipLabel(summary.characters)
38
+ const char = allocateField('paste', label, 'composer')
39
+ if (char === null) {
40
+ value += segment.text
41
+ continue
42
+ }
43
+ fields.set(char, { kind: 'paste', label, text: segment.text })
44
+ value += char
45
+ }
46
+ return value
47
+ }
48
+
49
+ export function insertImageField(images: PendingImage[], fields: ComposerFieldMap): string {
50
+ if (images.length === 0) return ''
51
+ const label = imageChipLabel(images.length)
52
+ const char = allocateField('image', label, 'composer')
53
+ if (char === null) return images.length === 1 && images[0]!.kind === 'path' ? images[0]!.path : label
54
+ fields.set(char, { kind: 'image', label, images })
55
+ return char
56
+ }
57
+
58
+ export function insertClipboardImage(image: ClipboardImage, fields: ComposerFieldMap): string {
59
+ return insertImageField([{
60
+ kind: 'data',
61
+ data: image.data,
62
+ mediaType: image.mediaType,
63
+ name: 'clipboard',
64
+ }], fields)
65
+ }
66
+
67
+ export function insertFieldSpec(
68
+ spec: { kind: string; label: string; data?: unknown },
69
+ fields: ComposerFieldMap,
70
+ ): string {
71
+ const char = allocateField(spec.kind, spec.label, 'composer')
72
+ if (char === null) return spec.label
73
+ let field: ComposerField
74
+ if (spec.kind === 'paste') {
75
+ field = { kind: 'paste', label: spec.label, text: typeof spec.data === 'string' ? spec.data : spec.label }
76
+ } else if (spec.kind === 'image' && Array.isArray(spec.data)) {
77
+ field = { kind: 'image', label: spec.label, images: spec.data as PendingImage[] }
78
+ } else {
79
+ field = { kind: spec.kind, label: spec.label, data: spec.data }
80
+ }
81
+ fields.set(char, field)
82
+ return char
83
+ }
84
+
85
+ export function reconcileComposerFields(value: string, fields: ComposerFieldMap): void {
86
+ for (const char of fields.keys()) {
87
+ if (!value.includes(char)) fields.delete(char)
88
+ }
89
+ releaseUnreferenced('composer', value)
90
+ }
91
+
92
+ export interface ComposerSubmission {
93
+ text: string
94
+ images: PendingImage[][]
95
+ }
96
+
97
+ export function expandComposerValue(value: string, fields: ComposerFieldMap): ComposerSubmission {
98
+ let text = ''
99
+ const images: PendingImage[][] = []
100
+ for (const char of value) {
101
+ const field = isFieldChar(char) ? fields.get(char) : undefined
102
+ if (field === undefined) {
103
+ text += char
104
+ continue
105
+ }
106
+ if (field.kind === 'paste') text += field.text ?? ''
107
+ else if (field.kind === 'image' && (field.images ?? []).length > 0) images.push(field.images!)
108
+ else text += fieldExpandOf(field.kind)?.(field.data) ?? field.label
109
+ }
110
+ return { text: text.trim(), images }
111
+ }
@@ -0,0 +1,21 @@
1
+ import { keyedRegistry } from '../../kernel/registry.ts'
2
+ import type { TuiKey } from '../../contract/index.ts'
3
+
4
+ export interface ComposerKeyBinding {
5
+ id: string
6
+ order?: number
7
+ handle(input: string, key: TuiKey): boolean
8
+ }
9
+
10
+ const bindings = keyedRegistry<ComposerKeyBinding>()
11
+
12
+ export function registerComposerKeyBinding(binding: ComposerKeyBinding): () => void {
13
+ return bindings.register(binding.id, binding, { order: binding.order })
14
+ }
15
+
16
+ export function handleComposerKeyBindings(input: string, key: TuiKey): boolean {
17
+ for (const binding of bindings.values()) {
18
+ if (binding.handle(input, key) === true) return true
19
+ }
20
+ return false
21
+ }