@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,386 @@
1
+ import { Box, Text, useStdout } from 'ink'
2
+ import { createContext, useContext } from 'react'
3
+ import { useCaret } from '../hooks/use-caret.ts'
4
+ import type { ReactNode, Ref } from 'react'
5
+ import { useEffect, useImperativeHandle, useRef, useState } from 'react'
6
+ import { COLORS } from '../../theme.ts'
7
+ import { writeCursorShape } from '../../terminal/cursor-shape.ts'
8
+ import { textWidth, colToCharIndex, caretScrollStart, truncate } from '../../core/text.ts'
9
+ import { SelectableText } from '../selection.tsx'
10
+ import { Region } from '../region.tsx'
11
+ import { renderRow } from './dialog-item.tsx'
12
+ import { adjustScroll, hitRowIndex, rowHeight, rowTopOffset, wrapFooter, wrapStatusLines } from './geometry.ts'
13
+ import type { DialogFooterLine } from './geometry.ts'
14
+ import { ERROR_MAX_ROWS } from './sizes.ts'
15
+ import { asTextItem, clampFocus, filterRowsWithHeaders, focusedItem, isSelectableRow, selectableSpan, snapRow } from './items.ts'
16
+ import type { DialogFocus, DialogItem, DialogRow } from './items.ts'
17
+ import { applyNavigation, useDialogInput } from './use-dialog-input.ts'
18
+ import type { DialogLiveState } from './use-dialog-input.ts'
19
+ import { widgetOf } from '../widgets/registry.ts'
20
+ import type { ClickActions, ClickHit } from '../widgets/types.ts'
21
+ import type { WindowHandle } from '../../contract/index.ts'
22
+
23
+ export * from './items.ts'
24
+ export * from './geometry.ts'
25
+
26
+ export interface DialogProps {
27
+ width: number
28
+ maxHeight: number
29
+ title?: string
30
+ rows: DialogRow[]
31
+ footer?: DialogFooterLine[]
32
+ errors?: DialogFooterLine[]
33
+ onClose: () => void
34
+ search?: boolean
35
+ searchRight?: boolean
36
+ centerScroll?: boolean
37
+ onCtrlA?(focused: DialogItem | undefined): boolean
38
+ onCtrlD?(focused: DialogItem | undefined): boolean
39
+ onCtrlE?(focused: DialogItem | undefined): boolean
40
+ onActivity?(): void
41
+ ref?: Ref<DialogHandle>
42
+ }
43
+
44
+ export type DialogHandle = WindowHandle
45
+
46
+ export const CloseGuardContext = createContext(false)
47
+
48
+ export function Dialog({
49
+ width,
50
+ maxHeight,
51
+ title,
52
+ rows,
53
+ footer,
54
+ errors,
55
+ onClose,
56
+ search = false,
57
+ searchRight = false,
58
+ centerScroll = false,
59
+ onCtrlA,
60
+ onCtrlD,
61
+ onCtrlE,
62
+ onActivity,
63
+ ref,
64
+ }: DialogProps) {
65
+ const { stdout } = useStdout()
66
+ const { setCursorPosition } = useCaret()
67
+ const closeGuarded = useContext(CloseGuardContext)
68
+ const columns = stdout?.columns ?? 80
69
+ const totalRows = stdout?.rows ?? 24
70
+ const [scrollTop, setScrollTop] = useState(0)
71
+ const [cursor, setCursor] = useState(0)
72
+ const cursorSyncRef = useRef<{ row: number; col: number; type: string | undefined }>({ row: -1, col: -1, type: undefined })
73
+ const [searchValue, setSearchValue] = useState('')
74
+ const [carouselPress, setCarouselPress] = useState<{ row: number; side: 'left' | 'right' } | null>(null)
75
+ const carouselPressTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
76
+ const [errorScrollTop, setErrorScrollTop] = useState(0)
77
+ const handleSearch = (value: string) => {
78
+ setSearchValue(value)
79
+ setScrollTop(0)
80
+ }
81
+ const searchRow: DialogRow = { items: [{ type: 'search', value: searchValue, onChange: handleSearch }] }
82
+ const filteredRows = search ? filterRowsWithHeaders(rows, searchValue, searchRight) : rows
83
+ const displayRows = search ? [searchRow, ...filteredRows] : rows
84
+ const contentRows = search ? filteredRows : rows
85
+ const [focus, setFocus] = useState<DialogFocus>(() => ({ row: snapRow(search ? [searchRow, ...rows] : rows, search ? 1 : 0), col: 0 }))
86
+ const widthCells = width <= 1 ? Math.floor(columns * width) : width
87
+ const windowWidth = Math.min(Math.max(1, widthCells), columns)
88
+ const contentWidth = Math.max(1, windowWidth - 4)
89
+ const fixedHeight = search ? rowHeight(searchRow, contentWidth) : 0
90
+ const focusMinRow = search ? 1 : 0
91
+ const focusMaxRow = Math.max(0, displayRows.length - 1)
92
+ useEffect(() => {
93
+ setFocus(current => {
94
+ const next = clampFocus(displayRows, current, focusMinRow, focusMaxRow)
95
+ return next.row === current.row && next.col === current.col ? current : next
96
+ })
97
+ }, [displayRows, focusMinRow, focusMaxRow])
98
+ const errorKey = (errors ?? []).map(line => line.text).join('\n')
99
+ useEffect(() => {
100
+ setErrorScrollTop(0)
101
+ }, [errorKey])
102
+ const titleLines = title === undefined ? 0 : 2
103
+ const footerRows = wrapFooter(footer, contentWidth)
104
+ const extraHeight = footerRows.length > 0 ? 1 + footerRows.length : 0
105
+ const desired = displayRows.reduce((sum, row) => sum + rowHeight(row, contentWidth), 0) + titleLines + extraHeight + 2
106
+ const maxRows = Math.max(1, maxHeight <= 1 ? Math.floor(totalRows * maxHeight) : maxHeight)
107
+ const windowHeight = Math.min(desired, maxRows, totalRows)
108
+ const contentHeight = Math.max(1, windowHeight - 2 - titleLines - extraHeight)
109
+ const viewportHeight = Math.max(1, contentHeight - fixedHeight)
110
+ const top = Math.max(0, Math.floor((totalRows - windowHeight) / 2))
111
+ const left = Math.max(0, Math.floor((columns - windowWidth) / 2))
112
+ const frameLeft = 2
113
+ const errorRows = wrapStatusLines(errors, contentWidth)
114
+ const availableBelow = Math.max(0, totalRows - (top + windowHeight))
115
+ const errorVisible = Math.max(0, Math.min(ERROR_MAX_ROWS, errorRows.length, availableBelow - 1))
116
+ const maxErrorScroll = Math.max(0, errorRows.length - errorVisible)
117
+ const errorScroll = Math.min(errorScrollTop, maxErrorScroll)
118
+ const headerBottom = 1 + titleLines
119
+ const contentTop = headerBottom + fixedHeight
120
+ const safeFocus: DialogFocus = {
121
+ row: Math.min(Math.max(focus.row, 0), focusMaxRow),
122
+ col: Math.min(Math.max(focus.col, 0), selectableSpan(displayRows[focus.row])),
123
+ }
124
+ const contentRowsHeight = contentRows.reduce((sum, row) => sum + rowHeight(row, contentWidth), 0)
125
+ const maxScroll = Math.max(0, contentRowsHeight - viewportHeight)
126
+ const scroll = adjustScroll(contentRows, { row: Math.max(0, safeFocus.row - (search ? 1 : 0)), col: 0 }, Math.min(scrollTop, maxScroll), viewportHeight, contentWidth)
127
+ const live = useRef<DialogLiveState>({
128
+ rows: displayRows,
129
+ contentRows,
130
+ focus,
131
+ scrollTop: scroll,
132
+ cursor,
133
+ value: '',
134
+ searchValue,
135
+ viewportHeight,
136
+ focusMinRow,
137
+ focusMaxRow,
138
+ contentWidth,
139
+ top,
140
+ windowHeight,
141
+ })
142
+ live.current.rows = displayRows
143
+ live.current.contentRows = contentRows
144
+ live.current.focus = focus
145
+ live.current.scrollTop = scroll
146
+ live.current.searchValue = searchValue
147
+ live.current.viewportHeight = viewportHeight
148
+ live.current.focusMinRow = focusMinRow
149
+ live.current.focusMaxRow = focusMaxRow
150
+ live.current.contentWidth = contentWidth
151
+ live.current.top = top
152
+ live.current.windowHeight = windowHeight
153
+ const errorLive = useRef({ top: 0, height: 0, maxScroll: 0 })
154
+ errorLive.current = { top: top + windowHeight, height: errorVisible + 1, maxScroll: maxErrorScroll }
155
+ const current = focusedItem(displayRows[safeFocus.row], safeFocus.col)
156
+ const currentText = current === undefined ? null : asTextItem(current)
157
+ live.current.value = currentText?.value ?? ''
158
+ const cursorSynced = cursorSyncRef.current.row === safeFocus.row
159
+ && cursorSyncRef.current.col === safeFocus.col
160
+ && cursorSyncRef.current.type === current?.type
161
+ const effectiveCursor = cursorSynced || currentText === null || currentText.type === 'search'
162
+ ? cursor
163
+ : currentText.value.length
164
+ live.current.cursor = effectiveCursor
165
+ useEffect(() => {
166
+ cursorSyncRef.current = { row: safeFocus.row, col: safeFocus.col, type: current?.type }
167
+ if (currentText !== null && currentText.type !== 'search') {
168
+ setCursor(currentText.value.length)
169
+ }
170
+ }, [current?.type, safeFocus.row, safeFocus.col, displayRows.length])
171
+ const editingFormInput = current !== undefined && current.type !== 'search' && asTextItem(current) !== null
172
+ if (search && !editingFormInput) {
173
+ const caret = Math.min(Math.max(0, cursor), searchValue.length)
174
+ const start = caretScrollStart(searchValue, caret, contentWidth)
175
+ setCursorPosition({
176
+ x: left + frameLeft + textWidth(searchValue.slice(start, caret)),
177
+ y: top + headerBottom,
178
+ })
179
+ } else {
180
+ const caretOffset = current === undefined ? undefined : widgetOf(current.type).caret?.(current, effectiveCursor, contentWidth)
181
+ if (caretOffset === undefined) {
182
+ setCursorPosition(undefined)
183
+ } else {
184
+ const contentIndex = Math.max(0, safeFocus.row - (search ? 1 : 0))
185
+ const rowOffset = rowTopOffset(contentRows, contentIndex, contentWidth)
186
+ setCursorPosition({
187
+ x: left + frameLeft + caretOffset.dx,
188
+ y: top + contentTop + rowOffset + caretOffset.dy - scroll,
189
+ })
190
+ }
191
+ }
192
+ useEffect(() => {
193
+ if (search && !editingFormInput) {
194
+ writeCursorShape('beam')
195
+ return
196
+ }
197
+ const beam = current !== undefined && widgetOf(current.type).caret !== undefined
198
+ writeCursorShape(beam ? 'beam' : 'block')
199
+ }, [current?.type, safeFocus.row, safeFocus.col])
200
+ useEffect(() => {
201
+ return () => {
202
+ writeCursorShape('reset')
203
+ clearTimeout(carouselPressTimer.current)
204
+ }
205
+ }, [])
206
+ useDialogInput({
207
+ live,
208
+ search,
209
+ closeGuarded,
210
+ centerScroll,
211
+ onClose,
212
+ onCtrlA,
213
+ onCtrlD,
214
+ onCtrlE,
215
+ onActivity,
216
+ requestSearch: handleSearch,
217
+ setFocus,
218
+ setScrollTop,
219
+ setCursor,
220
+ })
221
+ const flashCarousel = (row: number, side: 'left' | 'right') => {
222
+ setCarouselPress({ row, side })
223
+ clearTimeout(carouselPressTimer.current)
224
+ carouselPressTimer.current = setTimeout(() => setCarouselPress(null), 120)
225
+ }
226
+ useImperativeHandle(ref, () => ({
227
+ wheelAt(y, dir) {
228
+ onActivity?.()
229
+ if (y < live.current.top || y >= live.current.top + live.current.windowHeight) {
230
+ const error = errorLive.current
231
+ if (error.height > 1 && y >= error.top && y < error.top + error.height) {
232
+ setErrorScrollTop(current => Math.max(0, Math.min(current + dir, error.maxScroll)))
233
+ return true
234
+ }
235
+ return false
236
+ }
237
+ applyNavigation(live.current, dir === -1 ? 'up' : 'down', search, { setFocus, setScrollTop }, centerScroll)
238
+ return true
239
+ },
240
+ clickAt(y, x) {
241
+ onActivity?.()
242
+ if (y < top || y >= top + windowHeight || x < left || x >= left + windowWidth) return
243
+ if (search) {
244
+ const searchTop = top + headerBottom
245
+ if (y >= searchTop && y < searchTop + fixedHeight) {
246
+ const clickCaret = safeFocus.row === 0 ? Math.min(cursor, searchValue.length) : searchValue.length
247
+ const start = caretScrollStart(searchValue, clickCaret, contentWidth)
248
+ const visible = truncate(searchValue.slice(start), contentWidth)
249
+ const col = Math.max(0, Math.min(x - (left + frameLeft), textWidth(visible)))
250
+ setFocus({ row: 0, col: 0 })
251
+ setCursor(Math.min(searchValue.length, start + colToCharIndex(visible, col)))
252
+ return
253
+ }
254
+ }
255
+ const rowIndex = hitRowIndex(y, top + fixedHeight, titleLines, contentRows, scroll, contentWidth)
256
+ if (rowIndex === null) return
257
+ const rowSpec = contentRows[rowIndex]
258
+ if (!isSelectableRow(rowSpec)) return
259
+ const displayRow = Math.min(Math.max(rowIndex + (search ? 1 : 0), focusMinRow), focusMaxRow)
260
+ const rowOffset = rowTopOffset(contentRows, rowIndex, contentWidth)
261
+ const hit: ClickHit = {
262
+ localX: x - (left + frameLeft),
263
+ localY: y - (top + contentTop + rowOffset - scroll),
264
+ width: contentWidth,
265
+ }
266
+ const clickActions: ClickActions = {
267
+ focus: col => setFocus({ row: displayRow, col }),
268
+ flash: side => flashCarousel(rowIndex, side),
269
+ }
270
+ for (const item of rowSpec?.items ?? []) {
271
+ if (widgetOf(item.type).onClick?.(item, hit, clickActions) === true) return
272
+ }
273
+ if (safeFocus.row === displayRow && rowSpec !== undefined) {
274
+ const first = rowSpec.items[0]
275
+ if (first !== undefined) {
276
+ const activate = widgetOf(first.type).activate
277
+ if (activate !== undefined) {
278
+ activate(first)
279
+ return
280
+ }
281
+ }
282
+ }
283
+ setFocus({ row: displayRow, col: 0 })
284
+ const contentRow = Math.max(0, Math.min(rowIndex, contentRows.length - 1))
285
+ setScrollTop(adjustScroll(contentRows, { row: contentRow, col: 0 }, scroll, viewportHeight, contentWidth))
286
+ },
287
+ }))
288
+ const visibleRows: ReactNode[] = []
289
+ let offset = 0
290
+ for (let i = 0; i < contentRows.length; i++) {
291
+ const height = rowHeight(contentRows[i]!, contentWidth)
292
+ if (offset + height <= scroll) {
293
+ offset += height
294
+ continue
295
+ }
296
+ if (offset >= scroll + viewportHeight) break
297
+ const clip = Math.max(0, scroll - offset)
298
+ const rel = Math.max(0, offset - scroll)
299
+ const baseY = contentTop + rel
300
+ const focused = safeFocus.row === i + (search ? 1 : 0)
301
+ visibleRows.push(
302
+ <Box key={`row-${i}`} position="absolute" top={rel} left={0} width={contentWidth}>
303
+ {renderRow(contentRows[i], focused, contentWidth, baseY, frameLeft, carouselPress?.row === i ? carouselPress.side : null, focused ? safeFocus.col : 0, clip, focused ? effectiveCursor : undefined)}
304
+ </Box>,
305
+ )
306
+ offset += height
307
+ }
308
+ return (
309
+ <Region x={left} y={top}>
310
+ <Box
311
+ position="absolute"
312
+ top={top}
313
+ left={left}
314
+ width={windowWidth}
315
+ height={windowHeight}
316
+ flexDirection="column"
317
+ backgroundColor={COLORS.dialogBackground}
318
+ paddingLeft={2}
319
+ paddingRight={2}
320
+ paddingTop={1}
321
+ paddingBottom={1}
322
+ >
323
+ {title !== undefined && (
324
+ <>
325
+ <Box justifyContent="center">
326
+ <SelectableText y={1} col={frameLeft + Math.max(0, Math.floor((contentWidth - textWidth(title)) / 2))} text={title} />
327
+ </Box>
328
+ <Box height={1}>
329
+ <Text> </Text>
330
+ </Box>
331
+ </>
332
+ )}
333
+ <Box flexDirection="column">
334
+ {search && (
335
+ <Box flexDirection="column">
336
+ {renderRow(searchRow, safeFocus.row === 0, contentWidth, headerBottom, frameLeft, null, 0, 0, cursor)}
337
+ </Box>
338
+ )}
339
+ <Box flexDirection="column" height={viewportHeight} overflow="hidden">
340
+ {visibleRows}
341
+ </Box>
342
+ </Box>
343
+ {footerRows.length > 0 && (
344
+ <>
345
+ <Box height={1}>
346
+ <Text> </Text>
347
+ </Box>
348
+ {footerRows.map((line, index) => (
349
+ <SelectableText
350
+ key={index}
351
+ y={windowHeight - footerRows.length + index - 1}
352
+ col={frameLeft}
353
+ text={line.text}
354
+ color={line.color}
355
+ />
356
+ ))}
357
+ </>
358
+ )}
359
+ </Box>
360
+ {errorVisible > 0 && (
361
+ <Box
362
+ position="absolute"
363
+ top={top + windowHeight}
364
+ left={left}
365
+ width={windowWidth}
366
+ height={errorVisible + 1}
367
+ flexDirection="column"
368
+ backgroundColor={COLORS.dialogBackground}
369
+ paddingLeft={2}
370
+ paddingRight={2}
371
+ paddingBottom={1}
372
+ >
373
+ {errorRows.slice(errorScroll, errorScroll + errorVisible).map((line, index) => (
374
+ <SelectableText
375
+ key={errorScroll + index}
376
+ y={windowHeight + index}
377
+ col={frameLeft}
378
+ text={line.text}
379
+ color={line.color}
380
+ />
381
+ ))}
382
+ </Box>
383
+ )}
384
+ </Region>
385
+ )
386
+ }
@@ -0,0 +1,45 @@
1
+ import type { Ref } from 'react'
2
+ import { errorLine } from './status-lines.ts'
3
+ import type { EffortSummary } from '../../chat/efforts.ts'
4
+ import { useAsyncAction } from '../hooks/use-async-action.ts'
5
+ import { ListDialog } from './list-dialog.tsx'
6
+ import { DIALOG_WIDTH_MEDIUM, DIALOG_MAX_HEIGHT } from './sizes.ts'
7
+ import type { DialogHandle, DialogFooterLine } from './dialog.tsx'
8
+
9
+ export interface EffortsApi {
10
+ listEfforts(): Promise<EffortSummary[]>
11
+ currentEffort(): string | undefined
12
+ selectEffort(id: string): Promise<void>
13
+ }
14
+
15
+ export interface EffortDialogProps {
16
+ api: EffortsApi
17
+ onClose: () => void
18
+ ref?: Ref<DialogHandle>
19
+ }
20
+
21
+
22
+ export function EffortDialog({ api, onClose, ref }: EffortDialogProps) {
23
+ const { error, run } = useAsyncAction()
24
+ const current = api.currentEffort()
25
+ const selectEffort = async (id: string) => {
26
+ const result = await run(() => api.selectEffort(id))
27
+ if (!result.ok) return
28
+ onClose()
29
+ }
30
+ const footerLines: DialogFooterLine[] = error === null ? [] : [errorLine(error)]
31
+ return (
32
+ <ListDialog
33
+ ref={ref}
34
+ title="reasoning effort"
35
+ width={DIALOG_WIDTH_MEDIUM}
36
+ maxHeight={DIALOG_MAX_HEIGHT}
37
+ load={api.listEfforts}
38
+ labelOf={effort => effort.name}
39
+ rightOf={effort => (effort.id === current ? 'current' : undefined)}
40
+ onSelect={effort => void selectEffort(effort.id)}
41
+ onClose={onClose}
42
+ errors={footerLines}
43
+ />
44
+ )
45
+ }
@@ -0,0 +1,131 @@
1
+ import { textWidth, truncate, wrapLines } from '../../core/text.ts'
2
+ import { glyphs } from '../../terminal/glyphs.ts'
3
+ import type { DialogFocus, DialogRow } from './items.ts'
4
+ import { isSelectableRow } from './items.ts'
5
+ import { widgetOf } from '../widgets/registry.ts'
6
+
7
+ export const CAROUSEL_BUTTON_WIDTH = 3
8
+
9
+ const SELECT_BLOCK_WIDTH_RATIO = 0.6
10
+ const ACTION_EDGE_RATIO = 0.2
11
+
12
+ export interface DialogFooterLine {
13
+ text: string
14
+ color?: string
15
+ }
16
+
17
+ interface FooterRenderLine {
18
+ text: string
19
+ color?: string
20
+ }
21
+
22
+ const FOOTER_MAX_LINES_PER_ENTRY = 2
23
+
24
+ function wrapFooter(footer: DialogFooterLine[] | undefined, width: number): FooterRenderLine[] {
25
+ if (footer === undefined) return []
26
+ const rows: FooterRenderLine[] = []
27
+ for (const line of footer) {
28
+ if (line.text === '') continue
29
+ const wrapped = wrapLines(line.text, width)
30
+ const capped = wrapped.slice(0, FOOTER_MAX_LINES_PER_ENTRY)
31
+ if (wrapped.length > FOOTER_MAX_LINES_PER_ENTRY) {
32
+ capped[1] = truncate(capped[1] ?? '', Math.max(1, width - glyphs.ellipsis.length)) + glyphs.ellipsis
33
+ }
34
+ for (const text of capped) rows.push({ text, color: line.color })
35
+ }
36
+ return rows
37
+ }
38
+
39
+ export { wrapFooter }
40
+
41
+ export function wrapStatusLines(lines: DialogFooterLine[] | undefined, width: number): FooterRenderLine[] {
42
+ if (lines === undefined) return []
43
+ const rows: FooterRenderLine[] = []
44
+ for (const line of lines) {
45
+ if (line.text === '') continue
46
+ for (const text of wrapLines(line.text, width)) rows.push({ text, color: line.color })
47
+ }
48
+ return rows
49
+ }
50
+
51
+ export function rowHeight(row: DialogRow, width: number): number {
52
+ let height = 1
53
+ for (const item of row.items) {
54
+ const itemHeight = widgetOf(item.type).height(item, width)
55
+ if (itemHeight > height) height = itemHeight
56
+ }
57
+ return height
58
+ }
59
+
60
+ export function rowTopOffset(rows: DialogRow[], rowIndex: number, width: number): number {
61
+ let offset = 0
62
+ for (let i = 0; i < rowIndex; i++) offset += rowHeight(rows[i]!, width)
63
+ return offset
64
+ }
65
+
66
+ export function rowBlockSpan(rows: DialogRow[], rowIndex: number, width: number): { top: number; bottom: number } {
67
+ const top = rowTopOffset(rows, rowIndex, width)
68
+ let blockTop = top
69
+ let bottom = top + rowHeight(rows[rowIndex] ?? { items: [] }, width) - 1
70
+ for (let i = rowIndex - 1; i >= 0 && !isSelectableRow(rows[i]); i--) blockTop -= rowHeight(rows[i]!, width)
71
+ for (let i = rowIndex + 1; i < rows.length && !isSelectableRow(rows[i]); i++) bottom += rowHeight(rows[i]!, width)
72
+ return { top: blockTop, bottom }
73
+ }
74
+
75
+ export function adjustScroll(rows: DialogRow[], focus: DialogFocus, scrollTop: number, contentHeight: number, width: number): number {
76
+ const { top, bottom } = rowBlockSpan(rows, focus.row, width)
77
+ if (top < scrollTop) return top
78
+ if (bottom >= scrollTop + contentHeight) return Math.max(0, bottom - contentHeight + 1)
79
+ return scrollTop
80
+ }
81
+
82
+ export function listCenterScroll(rows: DialogRow[], focusRow: number, viewportHeight: number, width: number, direction: 'up' | 'down'): number {
83
+ const total = rows.reduce((sum, row) => sum + rowHeight(row, width), 0)
84
+ const maxScroll = Math.max(0, total - viewportHeight)
85
+ const anchor = direction === 'down' ? Math.floor(viewportHeight / 2) : Math.floor((viewportHeight - 1) / 2)
86
+ const top = rowTopOffset(rows, focusRow, width)
87
+ return Math.max(0, Math.min(top - anchor, maxScroll))
88
+ }
89
+
90
+ export function hitRowIndex(y: number, top: number, titleLines: number, rows: DialogRow[], scrollTop = 0, width = Number.POSITIVE_INFINITY): number | null {
91
+ const localY = y - top - 1 - titleLines + scrollTop
92
+ let offset = 0
93
+ for (let i = 0; i < rows.length; i++) {
94
+ const height = rowHeight(rows[i]!, width)
95
+ if (localY >= offset && localY < offset + height) return i
96
+ offset += height
97
+ }
98
+ return null
99
+ }
100
+
101
+ export interface SelectBlock {
102
+ blockWidth: number
103
+ blockStart: number
104
+ text: string
105
+ leftPad: number
106
+ fullLeftPad: number
107
+ cursorX: number
108
+ }
109
+
110
+ export function selectBlock(contentWidth: number, value: string): SelectBlock {
111
+ const blockWidth = Math.max(3, Math.floor(contentWidth * SELECT_BLOCK_WIDTH_RATIO))
112
+ const innerWidth = Math.max(1, blockWidth - CAROUSEL_BUTTON_WIDTH * 2)
113
+ const text = truncate(value || '(none)', innerWidth)
114
+ const padding = innerWidth - textWidth(text)
115
+ const fullPadding = blockWidth - textWidth(text)
116
+ return {
117
+ blockWidth,
118
+ blockStart: contentWidth - blockWidth,
119
+ text,
120
+ leftPad: Math.floor(padding / 2),
121
+ fullLeftPad: Math.floor(fullPadding / 2),
122
+ cursorX: CAROUSEL_BUTTON_WIDTH + Math.floor(padding / 2) + textWidth(text),
123
+ }
124
+ }
125
+
126
+ export function actionPositions(contentWidth: number, confirmLabel: string, cancelLabel: string): { confirmX: number; cancelX: number } {
127
+ const edge = Math.floor(contentWidth * ACTION_EDGE_RATIO)
128
+ const confirmX = Math.min(edge, Math.max(0, contentWidth - textWidth(confirmLabel)))
129
+ const cancelX = Math.max(confirmX + textWidth(confirmLabel), contentWidth - edge - textWidth(cancelLabel))
130
+ return { confirmX, cancelX }
131
+ }
@@ -0,0 +1,124 @@
1
+ import { widgetOf } from '../widgets/registry.ts'
2
+ import type { DialogItemKinds } from '../../contract/index.ts'
3
+
4
+ export interface DialogRow {
5
+ items: DialogItem[]
6
+ }
7
+
8
+ type PluginDialogItem = DialogItemKinds[keyof DialogItemKinds]
9
+
10
+ export type DialogItem =
11
+ | { type: 'input'; label: string; value: string; onChange: (value: string) => void; onEnter?: () => void }
12
+ | { type: 'select'; label: string; value: string; options: string[]; onChange: (value: string) => void; onEnter?: () => void; spaced?: boolean }
13
+ | { type: 'search'; value: string; onChange: (value: string) => void }
14
+ | { type: 'button'; label: string; right?: string; rightColor?: string; onPress: () => void }
15
+ | { type: 'checkbox'; label: string; checked: boolean; onToggle: () => void; onConfirm: () => void }
16
+ | { type: 'header'; label: string; leadingBlank?: boolean }
17
+ | { type: 'static'; label: string }
18
+ | { type: 'actions'; confirmLabel: string; cancelLabel: string; onConfirm: () => void; onCancel: () => void }
19
+ | { type: string; data?: unknown }
20
+ | PluginDialogItem
21
+
22
+ export interface DialogFocus {
23
+ row: number
24
+ col: number
25
+ }
26
+
27
+ export function isSelectableRow(row: DialogRow | undefined): boolean {
28
+ if (row === undefined) return false
29
+ return row.items.some(item => widgetOf(item.type).selectable === true)
30
+ }
31
+
32
+ export function selectableSpan(row: DialogRow | undefined): number {
33
+ if (row === undefined) return 0
34
+ let stops = 0
35
+ for (const item of row.items) {
36
+ stops += widgetOf(item.type).stops?.(item) ?? 1
37
+ }
38
+ return Math.max(0, stops - 1)
39
+ }
40
+
41
+ export function snapRow(rows: DialogRow[], row: number): number {
42
+ const count = rows.length
43
+ if (count === 0) return 0
44
+ const index = Math.min(Math.max(row, 0), count - 1)
45
+ if (isSelectableRow(rows[index])) return index
46
+ for (let down = index + 1; down < count; down++) {
47
+ if (isSelectableRow(rows[down])) return down
48
+ }
49
+ for (let up = index - 1; up >= 0; up--) {
50
+ if (isSelectableRow(rows[up])) return up
51
+ }
52
+ return index
53
+ }
54
+
55
+ function stepRow(rows: DialogRow[], from: number, delta: -1 | 1): number {
56
+ let row = from + delta
57
+ while (row >= 0 && row < rows.length && !isSelectableRow(rows[row])) row += delta
58
+ return row >= 0 && row < rows.length ? row : from
59
+ }
60
+
61
+ function focusedItem(row: DialogRow | undefined, col: number): DialogItem | undefined {
62
+ if (row === undefined) return undefined
63
+ const only = row.items.length === 1 ? row.items[0] : undefined
64
+ if (only !== undefined && widgetOf(only.type).fullRowFocus === true) return only
65
+ return row.items[col]
66
+ }
67
+
68
+ export { focusedItem }
69
+
70
+ export function moveFocus(rows: DialogRow[], focus: DialogFocus, key: 'up' | 'down' | 'left' | 'right'): DialogFocus {
71
+ switch (key) {
72
+ case 'up':
73
+ return { row: stepRow(rows, focus.row, -1), col: 0 }
74
+ case 'down':
75
+ return { row: stepRow(rows, focus.row, 1), col: 0 }
76
+ case 'left':
77
+ return { row: focus.row, col: Math.max(0, focus.col - 1) }
78
+ case 'right': {
79
+ const max = selectableSpan(rows[focus.row])
80
+ return { row: focus.row, col: Math.max(0, Math.min(max, focus.col + 1)) }
81
+ }
82
+ }
83
+ }
84
+
85
+ export function clampFocus(rows: DialogRow[], focus: DialogFocus, minRow: number, maxRow: number): DialogFocus {
86
+ const row = snapRow(rows, Math.min(Math.max(focus.row, minRow), maxRow))
87
+ return { row, col: Math.min(focus.col, selectableSpan(rows[row])) }
88
+ }
89
+
90
+ export type TextItem = Extract<DialogItem, { type: 'input' }> | Extract<DialogItem, { type: 'search' }>
91
+
92
+ export function asTextItem(item: DialogItem): TextItem | null {
93
+ return widgetOf(item.type).editable === true ? item as TextItem : null
94
+ }
95
+
96
+ export function filterRowsWithHeaders(rows: DialogRow[], query: string, searchRight: boolean): DialogRow[] {
97
+ const q = query.trim().toLowerCase()
98
+ if (q === '') return rows
99
+ const matches = (item: DialogItem): boolean => {
100
+ const texts = widgetOf(item.type).searchTexts?.(item, searchRight)
101
+ if (texts === undefined) return false
102
+ return texts.some(text => text.toLowerCase().includes(q))
103
+ }
104
+ const out: DialogRow[] = []
105
+ let pendingHeader: DialogRow | undefined
106
+ for (const row of rows) {
107
+ if (!isSelectableRow(row)) {
108
+ pendingHeader = row
109
+ continue
110
+ }
111
+ if (!row.items.some(matches)) continue
112
+ if (pendingHeader !== undefined) {
113
+ out.push(pendingHeader)
114
+ pendingHeader = undefined
115
+ }
116
+ out.push(row)
117
+ }
118
+ const first = out[0]
119
+ if (first !== undefined && first.items.length === 1 && first.items[0]?.type === 'header') {
120
+ const header = first.items[0] as { type: 'header'; label: string; leadingBlank?: boolean }
121
+ out[0] = { items: [{ ...header, leadingBlank: false }] }
122
+ }
123
+ return out
124
+ }