@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,424 @@
1
+ import { inputLayout, moveCaretLine } from '../../core/composer-layout.ts'
2
+ import { INPUT_WIDTH_OFFSET } from '../../core/metrics.ts'
3
+ import { textWidth, wrapLines } from '../../core/text.ts'
4
+ import { editInsert, editBackspace } from '../../core/edit.ts'
5
+ import { caretNonceBold, caretNonceColor, caretNonceText } from '../../core/caret-nonce.ts'
6
+ import type { EditState } from '../../core/edit.ts'
7
+ import { mergeRuns } from '../../core/segments.ts'
8
+ import type { MarkStyle, Segment } from '../../core/segments.ts'
9
+ import { COLORS } from '../../theme.ts'
10
+ import { glyphs } from '../../terminal/glyphs.ts'
11
+ import { panelLegend } from './surface.tsx'
12
+ import { answerSummary } from '../../chat/question-view.ts'
13
+ import type { AskQuestionItemLike, AskUserQuestionAnswerItemLike, AskUserQuestionRequestLike } from '../../chat/interactions.ts'
14
+
15
+ const questionStyle = (): MarkStyle => ({ color: COLORS.panelQuestionText, bold: true })
16
+ const focusedLabelStyle = (): MarkStyle => ({ color: COLORS.workspaceWriteText })
17
+ const descriptionStyle = (): MarkStyle => ({ color: COLORS.toolBodyText })
18
+ const checkStyle = (): MarkStyle => ({ color: COLORS.success })
19
+ const unansweredStyle = (): MarkStyle => ({ color: COLORS.errorText })
20
+
21
+ function seg(text: string, style?: MarkStyle): Segment {
22
+ return { text, style: style ?? {} }
23
+ }
24
+
25
+ function lineOf(parts: Array<string | Segment>): Segment[] {
26
+ return mergeRuns(parts.map(part => typeof part === 'string' ? seg(part) : part))
27
+ }
28
+
29
+ function pushWrapped(target: Segment[][], text: string, indent: number, width: number, style?: MarkStyle): void {
30
+ const lines = wrapLines(text, Math.max(4, width - indent))
31
+ target.push(lineOf([' '.repeat(indent), seg(lines[0]!, style)]))
32
+ for (const line of lines.slice(1)) {
33
+ target.push(lineOf([' '.repeat(indent), seg(line, style)]))
34
+ }
35
+ }
36
+
37
+ export const NUMBER_COL = 2
38
+ export const SINGLE_LABEL_COL = 5
39
+ export const MULTI_LABEL_COL = 9
40
+ export const MAX_FIELD_ROWS = 3
41
+ export const MAX_BODY_ROWS = 14
42
+ export const MAX_DETAIL_ROWS = 7
43
+
44
+ export interface QuestionDraft {
45
+ selected: string[]
46
+ customText: string
47
+ customChecked: boolean
48
+ }
49
+
50
+ export interface QuestionPageState {
51
+ request: AskUserQuestionRequestLike
52
+ drafts: QuestionDraft[]
53
+ page: number
54
+ cursors: number[]
55
+ editing: boolean
56
+ editor: EditState
57
+ detailScroll: number[]
58
+ }
59
+
60
+ export function initialPageState(request: AskUserQuestionRequestLike): QuestionPageState {
61
+ return {
62
+ request,
63
+ drafts: request.questions.map(() => ({ selected: [], customText: '', customChecked: false })),
64
+ page: 0,
65
+ cursors: request.questions.map(() => 0),
66
+ editing: false,
67
+ editor: { value: '', cursor: 0 },
68
+ detailScroll: request.questions.map(() => 0),
69
+ }
70
+ }
71
+
72
+ export function scrollDetail(state: QuestionPageState, delta: number): QuestionPageState {
73
+ if (state.editing) return state
74
+ const next = Math.max(0, state.detailScroll[state.page]! + delta)
75
+ if (next === state.detailScroll[state.page]) return state
76
+ return {
77
+ ...state,
78
+ detailScroll: state.detailScroll.map((value, index) => index === state.page ? next : value),
79
+ }
80
+ }
81
+
82
+ export function interactiveRowCount(question: AskQuestionItemLike): number {
83
+ return (question.options?.length ?? 0) + 1
84
+ }
85
+
86
+ function labelCol(question: AskQuestionItemLike): number {
87
+ return question.multiSelect === true ? MULTI_LABEL_COL : SINGLE_LABEL_COL
88
+ }
89
+
90
+ function isCustomRow(question: AskQuestionItemLike, row: number): boolean {
91
+ return row >= (question.options?.length ?? 0)
92
+ }
93
+
94
+ export function beginEdit(state: QuestionPageState): QuestionPageState {
95
+ const draft = state.drafts[state.page]!
96
+ return {
97
+ ...state,
98
+ editing: true,
99
+ editor: { value: draft.customText, cursor: draft.customText.length },
100
+ }
101
+ }
102
+
103
+ export function commitEdit(state: QuestionPageState): QuestionPageState {
104
+ const question = state.request.questions[state.page]
105
+ if (question === undefined || !state.editing) return state
106
+ const text = state.editor.value.trim()
107
+ return {
108
+ ...state,
109
+ editing: false,
110
+ editor: { value: '', cursor: 0 },
111
+ drafts: state.drafts.map((draft, index) => index === state.page
112
+ ? {
113
+ ...draft,
114
+ customText: state.editor.value,
115
+ customChecked: text !== '',
116
+ ...(question.multiSelect !== true && text !== '' ? { selected: [] } : {}),
117
+ }
118
+ : draft),
119
+ }
120
+ }
121
+
122
+ export function cancelEdit(state: QuestionPageState): QuestionPageState {
123
+ return { ...state, editing: false, editor: { value: '', cursor: 0 } }
124
+ }
125
+
126
+ export function toggleCustomChecked(state: QuestionPageState): QuestionPageState {
127
+ return {
128
+ ...state,
129
+ drafts: state.drafts.map((draft, index) => index === state.page
130
+ ? { ...draft, customChecked: !draft.customChecked }
131
+ : draft),
132
+ }
133
+ }
134
+
135
+ export function toggleOption(state: QuestionPageState): QuestionPageState {
136
+ const question = state.request.questions[state.page]
137
+ if (question === undefined) return state
138
+ const row = state.cursors[state.page]!
139
+ if (isCustomRow(question, row)) return toggleCustomChecked(state)
140
+ const option = question.options![row]!
141
+ if (question.multiSelect === true) {
142
+ return {
143
+ ...state,
144
+ drafts: state.drafts.map((draft, index) => index === state.page
145
+ ? {
146
+ ...draft,
147
+ selected: draft.selected.includes(option.label)
148
+ ? draft.selected.filter(label => label !== option.label)
149
+ : [...draft.selected, option.label],
150
+ }
151
+ : draft),
152
+ }
153
+ }
154
+ return {
155
+ ...state,
156
+ drafts: state.drafts.map((draft, index) => index === state.page
157
+ ? { ...draft, selected: [option.label], customChecked: false }
158
+ : draft),
159
+ }
160
+ }
161
+
162
+ export function enterOnRow(state: QuestionPageState): QuestionPageState {
163
+ const question = state.request.questions[state.page]
164
+ if (question === undefined || state.editing) return commitEdit(state)
165
+ const draft = state.drafts[state.page]!
166
+ const row = state.cursors[state.page]!
167
+ if (!isCustomRow(question, row)) return toggleOption(state)
168
+ if (draft.customChecked) return toggleCustomChecked(state)
169
+ return beginEdit(state)
170
+ }
171
+
172
+ export function moveCursor(state: QuestionPageState, delta: -1 | 1): QuestionPageState {
173
+ const question = state.request.questions[state.page]
174
+ if (question === undefined || state.editing) return state
175
+ const count = interactiveRowCount(question)
176
+ if (count === 0) return state
177
+ const next = ((state.cursors[state.page]! + delta) % count + count) % count
178
+ return {
179
+ ...state,
180
+ cursors: state.cursors.map((value, index) => index === state.page ? next : value),
181
+ }
182
+ }
183
+
184
+ export function switchPage(state: QuestionPageState, delta: -1 | 1): QuestionPageState {
185
+ if (state.editing) return state
186
+ const total = state.request.questions.length + 1
187
+ return { ...state, page: Math.max(0, Math.min(total - 1, state.page + delta)) }
188
+ }
189
+
190
+ export function editorInsert(state: QuestionPageState, text: string): QuestionPageState {
191
+ if (!state.editing || text === '') return state
192
+ return { ...state, editor: editInsert(state.editor, text) }
193
+ }
194
+
195
+ export function editorBackspace(state: QuestionPageState): QuestionPageState {
196
+ if (!state.editing) return state
197
+ const next = editBackspace(state.editor)
198
+ return next === null ? state : { ...state, editor: next }
199
+ }
200
+
201
+ export function fieldWidthOf(question: AskQuestionItemLike, innerWidth: number): number {
202
+ return Math.max(8, innerWidth - labelCol(question))
203
+ }
204
+
205
+ export function editorCaret(state: QuestionPageState, deltaCol: number, deltaLine: number, fieldWidth: number): QuestionPageState {
206
+ if (!state.editing) return state
207
+ const value = state.editor.value
208
+ let cursor = state.editor.cursor
209
+ if (deltaCol !== 0) cursor = Math.max(0, Math.min(value.length, cursor + deltaCol))
210
+ if (deltaLine !== 0) cursor = moveCaretLine(value, cursor, fieldWidth, deltaLine === 1 ? 1 : -1)
211
+ return { ...state, editor: { value, cursor } }
212
+ }
213
+
214
+ export function buildAnswers(state: QuestionPageState): AskUserQuestionAnswerItemLike[] {
215
+ return state.request.questions.map((question, index) => {
216
+ const draft = state.drafts[index]!
217
+ const labels = (question.options ?? []).filter(option => draft.selected.includes(option.label)).map(option => option.label)
218
+ const custom = draft.customText.trim()
219
+ if (draft.customChecked && custom !== '') {
220
+ if (question.multiSelect === true) {
221
+ return { id: question.id, selected: labels, ...(custom === '' ? {} : { custom }) }
222
+ }
223
+ return { id: question.id, selected: [], custom }
224
+ }
225
+ return { id: question.id, selected: labels }
226
+ })
227
+ }
228
+
229
+ export function reviewAnswerOf(question: AskQuestionItemLike, draft: QuestionDraft): string {
230
+ const labels = (question.options ?? []).filter(option => draft.selected.includes(option.label)).map(option => option.label)
231
+ const custom = draft.customText.trim()
232
+ return answerSummary(question, labels, custom, draft.customChecked)
233
+ }
234
+
235
+ export interface PanelHitRow {
236
+ line: number
237
+ optionIndex: number
238
+ }
239
+
240
+ export interface PanelLayout {
241
+ lines: Segment[][]
242
+ focusLine: number
243
+ caret: { row: number; col: number } | null
244
+ height: number
245
+ hitRows: PanelHitRow[]
246
+ }
247
+
248
+ export function questionPanelLayout(state: QuestionPageState, innerWidth: number, scrollWindow: number = MAX_BODY_ROWS): PanelLayout {
249
+ const total = state.request.questions.length + 1
250
+ const isReview = state.page >= state.request.questions.length
251
+ const body: Segment[][] = []
252
+ const hitRows: PanelHitRow[] = []
253
+ let focusLine = 0
254
+ let caret: { row: number; col: number } | null = null
255
+
256
+ if (isReview) {
257
+ body.push(lineOf([seg('Confirm Selection', questionStyle())]))
258
+ body.push([])
259
+ for (const [index, question] of state.request.questions.entries()) {
260
+ pushWrapped(body, `${index + 1}. ${question.question}`, NUMBER_COL, innerWidth)
261
+ const answer = reviewAnswerOf(question, state.drafts[index]!)
262
+ const answerStyle = answer === '(Question not answered)' ? unansweredStyle() : descriptionStyle()
263
+ pushWrapped(body, answer, SINGLE_LABEL_COL, innerWidth, answerStyle)
264
+ }
265
+ body.push([])
266
+ body.push(questionLegend(state.page, total))
267
+ } else {
268
+ const question = state.request.questions[state.page]!
269
+ const multi = question.multiSelect === true
270
+ const editing = state.editing
271
+ const labX = labelCol(question)
272
+ const fieldWidth = Math.max(8, innerWidth - labX)
273
+ const above: Segment[][] = []
274
+ pushWrapped(above, question.question, 0, innerWidth, questionStyle())
275
+ above.push(lineOf([]))
276
+
277
+ const below: Segment[][] = []
278
+ let focusInBelow = -1
279
+ let caretInBelow: { row: number; col: number } | null = null
280
+ const hitsInBelow: PanelHitRow[] = []
281
+ const rows = interactiveRowCount(question)
282
+ const cursorRow = state.cursors[state.page]!
283
+ const draft = state.drafts[state.page]!
284
+ for (let i = 0; i < rows; i++) {
285
+ const focused = cursorRow === i
286
+ const marker = focused ? glyphs.focusMarker : ' '
287
+ const num = `${i + 1}.`
288
+ if (focused) focusInBelow = below.length
289
+ hitsInBelow.push({ line: below.length, optionIndex: i })
290
+ if (i === rows - 1) {
291
+ const checked = draft.customChecked
292
+ const box = multi ? (checked ? glyphs.checkboxOn : glyphs.checkboxOff) : ''
293
+ const tick = !multi && checked ? ` ${glyphs.tick}` : ''
294
+ const value = editing ? state.editor.value : draft.customText
295
+ const cursor = editing ? state.editor.cursor : value.length
296
+ below.push(lineOf([
297
+ seg(marker, focused ? checkStyle() : undefined),
298
+ ' ',
299
+ seg(num, focused ? focusedLabelStyle() : undefined),
300
+ ...(box === '' ? [] : [seg(' ' + box, focused ? focusedLabelStyle() : undefined)]),
301
+ ' ',
302
+ seg('Custom input content', focused ? focusedLabelStyle() : undefined),
303
+ ...(tick === '' ? [] : [seg(tick, checkStyle())]),
304
+ ...(editing && focused ? [seg(caretNonceText(cursor), { color: caretNonceColor(cursor), bold: caretNonceBold(cursor) })] : []),
305
+ ]))
306
+ const field = inputLayout(value, cursor, fieldWidth + INPUT_WIDTH_OFFSET)
307
+ const firstVisible = Math.min(
308
+ Math.max(0, field.cursorRow - (MAX_FIELD_ROWS - 1)),
309
+ Math.max(0, field.lines.length - MAX_FIELD_ROWS),
310
+ )
311
+ const visible = field.lines.slice(firstVisible, firstVisible + MAX_FIELD_ROWS)
312
+ const fieldStart = below.length
313
+ for (const [index, line] of visible.entries()) {
314
+ below.push(lineOf([' '.repeat(labX), seg(line, descriptionStyle()), ...(editing && index === visible.length - 1 ? [seg(' ', descriptionStyle())] : [])]))
315
+ }
316
+ if (editing) {
317
+ caretInBelow = {
318
+ row: fieldStart + field.cursorRow - firstVisible,
319
+ col: labX + field.cursorCol,
320
+ }
321
+ }
322
+ } else {
323
+ const option = question.options![i]!
324
+ const chosen = draft.selected.includes(option.label)
325
+ const box = multi ? (chosen ? glyphs.checkboxOn : glyphs.checkboxOff) : ''
326
+ const tick = !multi && chosen ? ` ${glyphs.tick}` : ''
327
+ below.push(lineOf([
328
+ seg(marker, focused ? checkStyle() : undefined),
329
+ ' ',
330
+ seg(num, focused ? focusedLabelStyle() : undefined),
331
+ ...(box === '' ? [] : [seg(' ' + box, focused ? focusedLabelStyle() : undefined)]),
332
+ ' ',
333
+ seg(option.label, focused ? focusedLabelStyle() : undefined),
334
+ ...(tick === '' ? [] : [seg(tick, checkStyle())]),
335
+ ]))
336
+ if (option.description !== undefined && option.description !== '') {
337
+ pushWrapped(below, option.description, labX, innerWidth, descriptionStyle())
338
+ }
339
+ }
340
+ }
341
+ below.push(lineOf([]))
342
+ below.push(questionLegend(state.page, total))
343
+
344
+ body.push(...above)
345
+ const detail = question.detail === undefined || question.detail === '' || isPlanReview(question)
346
+ ? []
347
+ : wrapLines(question.detail, Math.max(8, innerWidth - SINGLE_LABEL_COL))
348
+ if (detail.length > 0) {
349
+ const budget = Math.max(3, scrollWindow - body.length - below.length)
350
+ if (detail.length <= budget) {
351
+ for (const line of detail) {
352
+ body.push(lineOf([' ', seg(line, descriptionStyle())]))
353
+ }
354
+ } else {
355
+ const contentRows = Math.max(1, budget - 2)
356
+ const maxStart = detail.length - contentRows
357
+ const start = Math.max(0, Math.min(state.detailScroll[state.page]!, maxStart))
358
+ if (start > 0) {
359
+ body.push(lineOf([seg(`${glyphs.ellipsis} ${start} lines above`, descriptionStyle())]))
360
+ }
361
+ for (const line of detail.slice(start, start + contentRows)) {
362
+ body.push(lineOf([' ', seg(line, descriptionStyle())]))
363
+ }
364
+ if (start + contentRows < detail.length) {
365
+ body.push(lineOf([seg(`${glyphs.ellipsis} ${detail.length - start - contentRows} more lines`, descriptionStyle())]))
366
+ }
367
+ }
368
+ }
369
+ const base = body.length
370
+ body.push(...below)
371
+ if (focusInBelow >= 0) focusLine = base + focusInBelow
372
+ if (caretInBelow !== null) caret = { row: base + caretInBelow.row, col: caretInBelow.col }
373
+ for (const hit of hitsInBelow) {
374
+ hitRows.push({ line: base + hit.line, optionIndex: hit.optionIndex })
375
+ }
376
+ }
377
+
378
+ const window = Math.max(3, Math.min(scrollWindow, body.length))
379
+ if (body.length <= window) {
380
+ return { lines: body, focusLine, caret: finalizeCaret(caret, body), height: body.length, hitRows }
381
+ }
382
+ let start = Math.max(0, Math.min(focusLine - Math.floor(window / 2), body.length - window))
383
+ const lines = body.slice(start, start + window)
384
+ const shifted = hitRows
385
+ .map(hit => ({ line: hit.line - start, optionIndex: hit.optionIndex }))
386
+ .filter(hit => hit.line >= 0 && hit.line < lines.length)
387
+ return { lines, focusLine: focusLine - start, caret: finalizeCaret(caret, body, start), height: window, hitRows: shifted }
388
+ }
389
+
390
+ function finalizeCaret(
391
+ caret: { row: number; col: number } | null,
392
+ body: Segment[][],
393
+ start = 0,
394
+ ): { row: number; col: number } | null {
395
+ if (caret === null) return null
396
+ const row = Math.max(0, Math.min(body.length - 1, caret.row)) - start
397
+ return { row, col: caret.col }
398
+ }
399
+
400
+ function questionLegend(page: number, total: number): Segment[] {
401
+ const prefix = `${page + 1}/${total}`
402
+ const review = page + 1 === total
403
+ const hints = review
404
+ ? [
405
+ { key: glyphs.pageFlip, description: 'page' },
406
+ { key: 'enter', description: 'submit' },
407
+ { key: 'esc', description: 'close' },
408
+ ]
409
+ : [
410
+ { key: glyphs.pageFlip, description: 'page' },
411
+ { key: glyphs.wrapArrows, description: 'wrap' },
412
+ { key: 'enter', description: 'select' },
413
+ { key: 'esc', description: 'close' },
414
+ ]
415
+ return panelLegend(hints, { prefix })
416
+ }
417
+
418
+ interface QuestionLike {
419
+ intent?: { kind?: string }
420
+ }
421
+
422
+ export function isPlanReview(question: QuestionLike): boolean {
423
+ return question.intent?.kind === 'plan-review'
424
+ }
@@ -0,0 +1,154 @@
1
+ import { Box, Text, useInput } from 'ink'
2
+ import { panelAnchorRow } from '../layout-service.ts'
3
+ import { isKeyConsumed } from '../key-arbiter.ts'
4
+ import { useEffect, useState } from 'react'
5
+ import { useCaret } from '../hooks/use-caret.ts'
6
+ import type { Ref } from 'react'
7
+ import { useImperativeHandle } from 'react'
8
+ import { isMouseResidue } from '../../terminal/mouse.ts'
9
+ import { writeCursorShape } from '../../terminal/cursor-shape.ts'
10
+ import { CHROME_MARGIN_X, CHROME_TEXT_X } from '../../core/metrics.ts'
11
+ import type { AskUserQuestionAnswerLike, QuestionPanelRequest } from '../../chat/interactions.ts'
12
+ import {
13
+ buildAnswers,
14
+ cancelEdit,
15
+ commitEdit,
16
+ editorBackspace,
17
+ editorCaret,
18
+ fieldWidthOf,
19
+ editorInsert,
20
+ enterOnRow,
21
+ initialPageState,
22
+ MAX_BODY_ROWS,
23
+ MAX_DETAIL_ROWS,
24
+ moveCursor,
25
+ questionPanelLayout,
26
+ scrollDetail,
27
+ switchPage,
28
+ } from './question-model.ts'
29
+ import type { QuestionPageState } from './question-model.ts'
30
+ import { PanelSurface } from './surface.tsx'
31
+ import type { PanelPointerHandle } from './surface.tsx'
32
+
33
+ export interface QuestionPanelProps {
34
+ handleRef?: Ref<PanelPointerHandle>
35
+ question: QuestionPanelRequest
36
+ background: string
37
+ active: boolean
38
+ columns: number
39
+ innerWidth: number
40
+ blockWidth: number
41
+ rows: number
42
+ onSubmit(answer: AskUserQuestionAnswerLike): void
43
+ onCancel(): void
44
+ onResize(height: number): void
45
+ }
46
+
47
+ export function QuestionPanel({ handleRef, question: panel, background, active, columns, innerWidth, blockWidth, rows, onSubmit, onCancel, onResize }: QuestionPanelProps) {
48
+ const [state, setState] = useState<QuestionPageState>(() => initialPageState(panel.request))
49
+ const { setCursorPosition } = useCaret()
50
+ const layout = questionPanelLayout(state, innerWidth, Math.min(MAX_BODY_ROWS, Math.max(3, rows - 6)))
51
+ const totalHeight = layout.height + 2
52
+ const bodyStart = panelAnchorRow(rows, layout.height)
53
+ useImperativeHandle(handleRef, () => ({
54
+ clickAt(y) {
55
+ if (!active || state.editing) return
56
+ const rel = y - bodyStart
57
+ const hit = layout.hitRows.find(entry => entry.line === rel)
58
+ if (hit === undefined) return
59
+ setState(current => {
60
+ if (current.cursors[current.page] === hit.optionIndex) return enterOnRow(current)
61
+ return {
62
+ ...current,
63
+ cursors: current.cursors.map((value, index) => index === current.page ? hit.optionIndex : value),
64
+ }
65
+ })
66
+ },
67
+ wheel(dir: -1 | 1) {
68
+ if (!active || state.editing) return false
69
+ setState(current => scrollDetail(current, dir === -1 ? -3 : 3))
70
+ return true
71
+ },
72
+ }))
73
+ if (active && state.editing && layout.caret !== null) {
74
+ setCursorPosition({
75
+ x: CHROME_TEXT_X + layout.caret.col,
76
+ y: bodyStart + layout.caret.row,
77
+ })
78
+ } else {
79
+ setCursorPosition(undefined)
80
+ }
81
+ useEffect(() => {
82
+ if (state.editing) {
83
+ writeCursorShape('show')
84
+ writeCursorShape('beam')
85
+ } else {
86
+ writeCursorShape('hide')
87
+ }
88
+ }, [state.editing])
89
+ useEffect(() => {
90
+ return () => {
91
+ writeCursorShape('show')
92
+ writeCursorShape('reset')
93
+ }
94
+ }, [])
95
+ useEffect(() => {
96
+ setState(initialPageState(panel.request))
97
+ }, [panel.id])
98
+ useEffect(() => {
99
+ onResize(totalHeight)
100
+ }, [totalHeight])
101
+ useInput((input, key) => {
102
+ if (!active || isKeyConsumed()) return
103
+ if (isMouseResidue(input)) return
104
+ if (state.editing) {
105
+ if (key.return) {
106
+ setState(current => commitEdit(current))
107
+ return
108
+ }
109
+ if (key.escape) {
110
+ setState(current => cancelEdit(current))
111
+ return
112
+ }
113
+ const fieldWidth = fieldWidthOf(state.request.questions[state.page]!, innerWidth)
114
+ if (key.upArrow) return setState(current => editorCaret(current, 0, -1, fieldWidth))
115
+ if (key.downArrow) return setState(current => editorCaret(current, 0, 1, fieldWidth))
116
+ if (key.leftArrow) return setState(current => editorCaret(current, -1, 0, fieldWidth))
117
+ if (key.rightArrow) return setState(current => editorCaret(current, 1, 0, fieldWidth))
118
+ if (key.home) return setState(current => ({ ...current, editor: { value: current.editor.value, cursor: 0 } }))
119
+ if (key.end) return setState(current => ({ ...current, editor: { value: current.editor.value, cursor: current.editor.value.length } }))
120
+ if (key.backspace || key.delete) return setState(current => editorBackspace(current))
121
+ if (key.ctrl && input === 'j') return setState(current => editorInsert(current, '\n'))
122
+ if (!key.ctrl && !key.meta && input.length > 0) return setState(current => editorInsert(current, input))
123
+ return
124
+ }
125
+ if (key.escape) {
126
+ onCancel()
127
+ return
128
+ }
129
+ if (key.leftArrow) return setState(current => switchPage(current, -1))
130
+ if (key.rightArrow) return setState(current => switchPage(current, 1))
131
+ if (key.pageUp) return setState(current => scrollDetail(current, -MAX_DETAIL_ROWS))
132
+ if (key.pageDown) return setState(current => scrollDetail(current, MAX_DETAIL_ROWS))
133
+ if (key.upArrow) return setState(current => moveCursor(current, -1))
134
+ if (key.downArrow) return setState(current => moveCursor(current, 1))
135
+ const isReview = state.page >= state.request.questions.length
136
+ if (key.return) {
137
+ if (isReview) {
138
+ onSubmit({ answers: buildAnswers(state) })
139
+ return
140
+ }
141
+ setState(current => enterOnRow(current))
142
+ }
143
+ })
144
+ return (
145
+ <PanelSurface
146
+ columns={columns}
147
+ rows={rows}
148
+ body={layout.lines.map(segments => ({ segments }))}
149
+ bodyStart={bodyStart}
150
+ background={background}
151
+ blockWidth={blockWidth}
152
+ />
153
+ )
154
+ }
@@ -0,0 +1,106 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { ReactNode } from 'react'
3
+ import { mergeRuns } from '../../core/segments.ts'
4
+ import type { Segment } from '../../core/segments.ts'
5
+ import { COLORS } from '../../theme.ts'
6
+ import { CHROME_MARGIN_X, CHROME_PAD_X, CHROME_TEXT_X } from '../../core/metrics.ts'
7
+ import { SelectableText } from '../selection.tsx'
8
+ import { Region } from '../region.tsx'
9
+ import { CapText } from '../chrome/caps.tsx'
10
+
11
+ export interface PanelPointerHandle {
12
+ clickAt(y: number, x: number): void
13
+ wheel(dir: -1 | 1): boolean
14
+ }
15
+
16
+ export interface PanelRow {
17
+ segments: Segment[]
18
+ /** Replaces the text row with an interactive node (e.g. a button row). */
19
+ content?: ReactNode
20
+ }
21
+
22
+ export interface PanelHint {
23
+ /** Key token, rendered with the key color (e.g. "enter", an arrow glyph). */
24
+ key: string
25
+ /** Action word, rendered with the description color. */
26
+ description: string
27
+ }
28
+
29
+ export interface PanelLegendOptions {
30
+ /** Leading counter text (e.g. "1/2"), rendered with the description color. */
31
+ prefix?: string
32
+ keyColor?: string
33
+ descriptionColor?: string
34
+ }
35
+
36
+ /**
37
+ * Build a key-hint row: keys in the panel key color, action words in the
38
+ * muted description color ("enter select esc close").
39
+ */
40
+ export function panelLegend(hints: readonly PanelHint[], options: PanelLegendOptions = {}): Segment[] {
41
+ const keyStyle = { color: options.keyColor ?? COLORS.panelKeyText }
42
+ const descriptionStyle = { color: options.descriptionColor ?? COLORS.toolBodyText }
43
+ const parts: Array<Segment> = []
44
+ if (options.prefix !== undefined && options.prefix !== '') {
45
+ parts.push({ text: options.prefix, style: descriptionStyle })
46
+ }
47
+ for (const [index, hint] of hints.entries()) {
48
+ if (index > 0 || parts.length > 0) parts.push({ text: ' ', style: descriptionStyle })
49
+ parts.push({ text: hint.key, style: keyStyle })
50
+ parts.push({ text: ' ', style: descriptionStyle })
51
+ parts.push({ text: hint.description, style: descriptionStyle })
52
+ }
53
+ return mergeRuns(parts)
54
+ }
55
+
56
+ /**
57
+ * Shared panel chrome: a floating block anchored above the status line with
58
+ * half-block caps, the panel background, and per-row text or interactive
59
+ * content. Builtin panels and plugin panels built through dshtui/dialog
60
+ * share this shell so every interaction panel looks the same.
61
+ *
62
+ * Selection pieces span the row text only, never the pill padding, so
63
+ * dragging over blank panel area selects nothing (same rule as messages).
64
+ */
65
+ export function PanelSurface({ columns, rows, body, bodyStart, background, blockWidth }: {
66
+ columns: number
67
+ rows: number
68
+ body: PanelRow[]
69
+ bodyStart: number
70
+ background: string
71
+ blockWidth: number
72
+ }) {
73
+ return (
74
+ <Region>
75
+ <Box position="absolute" top={0} left={0} width={columns} height={rows}>
76
+ <Box position="absolute" top={bodyStart - 1} left={CHROME_MARGIN_X} width={blockWidth}>
77
+ <CapText background={background} top width={blockWidth} />
78
+ </Box>
79
+ {body.map((row, index) => {
80
+ const isEmpty = row.segments.every(segment => segment.text.trim() === '')
81
+ return (
82
+ <Box
83
+ key={`panel-row-${index}`}
84
+ position="absolute"
85
+ top={bodyStart + index}
86
+ left={CHROME_MARGIN_X}
87
+ width={blockWidth}
88
+ paddingLeft={CHROME_PAD_X}
89
+ paddingRight={CHROME_PAD_X}
90
+ backgroundColor={background}
91
+ >
92
+ {row.content !== undefined
93
+ ? row.content
94
+ : isEmpty
95
+ ? <Text>{' '}</Text>
96
+ : <SelectableText y={bodyStart + index} col={CHROME_TEXT_X} segments={row.segments} />}
97
+ </Box>
98
+ )
99
+ })}
100
+ <Box position="absolute" top={bodyStart + body.length} left={CHROME_MARGIN_X} width={blockWidth}>
101
+ <CapText background={background} top={false} width={blockWidth} />
102
+ </Box>
103
+ </Box>
104
+ </Region>
105
+ )
106
+ }