@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,763 @@
1
+ import type { Message, ToolCardMessage } from '../../model/message.ts'
2
+ import type { CustomMessage } from '../../contract/index.ts'
3
+ import { colToCharIndex, textWidth, truncate, wrapLines } from '../../core/text.ts'
4
+ import { hasFieldChar } from '../../core/fields.ts'
5
+ import { expandFieldChars, fieldRowSegments } from '../../core/field-view.ts'
6
+ import { selectedRange } from '../../model/selection.ts'
7
+ import type { LineSelection } from '../../model/selection.ts'
8
+ import { sliceByColumns } from '../selection-registry.ts'
9
+ import { renderMarkdown, renderMarkdownStreaming, clearMarkdownStreamStates } from './md/index.ts'
10
+ import { renderToolDiffBody, renderToolReadBody } from './tool-diff.ts'
11
+ import { trimTrailingBlanks } from '../../chat/tool-view.ts'
12
+ import { messageViewOf } from './message-views.ts'
13
+ import { segmentsKey } from '../../core/segments.ts'
14
+ import type { Segment } from '../../core/segments.ts'
15
+ import { COLORS } from '../../theme.ts'
16
+ import { glyphs } from '../../terminal/glyphs.ts'
17
+ import { BUBBLE_WIDTH_OFFSET, HEADER_LABEL_COL } from '../../core/metrics.ts'
18
+ import { messageBackgroundWidth } from '../layout-service.ts'
19
+ import { messageRendererOf } from './renderers.ts'
20
+
21
+ export { HEADER_LABEL_COL }
22
+
23
+ export type RowKind = 'pad' | 'text' | 'header' | 'blank'
24
+
25
+ export interface RowInfo {
26
+ messageIndex: number
27
+ messageId: string
28
+ lineNo: number
29
+ kind: RowKind
30
+ text: string
31
+ colStart: number
32
+ selectable: boolean
33
+ clickable: boolean
34
+ background: boolean
35
+ backgroundWidth: number
36
+ muted: boolean
37
+ label: string
38
+ collapsed: boolean
39
+ thinking: boolean
40
+ role: 'user' | 'assistant' | 'error' | undefined
41
+ segments?: Segment[]
42
+ segKey?: string
43
+ spinner?: boolean
44
+ wave?: boolean
45
+ accent?: string
46
+ lineBg?: string
47
+ }
48
+
49
+ interface BodyRendered {
50
+ lines: string[]
51
+ rows: Segment[][] | null
52
+ bgs: (string | undefined)[] | null
53
+ customLabel?: string
54
+ customMuted?: boolean
55
+ }
56
+
57
+ type PlanRow =
58
+ | { type: 'pad'; role: RowInfo['role'] }
59
+ | { type: 'blank' }
60
+ | { type: 'header'; label: string; collapsed: boolean; clickable: boolean }
61
+ | {
62
+ type: 'body'
63
+ line: number
64
+ colStart: number
65
+ bg: boolean
66
+ muted: boolean
67
+ selectable: boolean
68
+ role: RowInfo['role']
69
+ wave?: boolean
70
+ }
71
+ | {
72
+ type: 'lit'
73
+ text: string
74
+ segments?: Segment[]
75
+ colStart: number
76
+ bg: boolean
77
+ muted: boolean
78
+ selectable: boolean
79
+ role: RowInfo['role']
80
+ wave?: boolean
81
+ }
82
+
83
+ interface MessageRender extends BodyRendered {
84
+ plan: PlanRow[]
85
+ }
86
+
87
+ let layoutEpoch = 0
88
+
89
+ export function clearLayoutCache(): void {
90
+ layoutEpoch += 1
91
+ clearMarkdownStreamStates()
92
+ }
93
+
94
+ const padRow = (role: RowInfo['role']): PlanRow => ({ type: 'pad', role })
95
+ const blankRow = (): PlanRow => ({ type: 'blank' })
96
+
97
+ function bodyRows(count: number, options: { colStart: number; bg: boolean; muted: boolean; selectable: boolean; role: RowInfo['role'] }): PlanRow[] {
98
+ const out: PlanRow[] = []
99
+ for (let line = 0; line < count; line++) {
100
+ out.push({ type: 'body', line, ...options })
101
+ }
102
+ return out
103
+ }
104
+
105
+ function renderBody(message: Message, width: number): BodyRendered {
106
+ const inner = width - BUBBLE_WIDTH_OFFSET
107
+ const override = messageRendererOf(message.kind)
108
+ if (override !== undefined) {
109
+ const rendered = override.render(message, inner)
110
+ if (rendered !== undefined) {
111
+ return {
112
+ lines: rendered.lines,
113
+ rows: rendered.rows ?? null,
114
+ bgs: rendered.bgs ?? null,
115
+ ...(rendered.customLabel === undefined ? {} : { customLabel: rendered.customLabel }),
116
+ ...(rendered.customMuted === undefined ? {} : { customMuted: rendered.customMuted }),
117
+ }
118
+ }
119
+ }
120
+ switch (message.kind) {
121
+ case 'bubble': {
122
+ if (message.role !== 'user' && message.origin === 'command') {
123
+ const lines = wrapLines(message.content, inner)
124
+ return { lines, rows: null, bgs: null }
125
+ }
126
+ if (message.role === 'assistant') {
127
+ const rendered = message.streaming === true
128
+ ? renderMarkdownStreaming(message.content, inner, false, `bubble:${message.id}`)
129
+ : renderMarkdown(message.content, inner, false, `bubble:${message.id}`)
130
+ return { lines: rendered.lines, rows: rendered.rows, bgs: null }
131
+ }
132
+ const lines = wrapLines(message.content, inner)
133
+ if (hasFieldChar(message.content)) {
134
+ return {
135
+ lines: lines.map(expandFieldChars),
136
+ rows: lines.map(line => fieldRowSegments(line, { background: COLORS.userBubbleBackground })),
137
+ bgs: null,
138
+ }
139
+ }
140
+ return { lines, rows: null, bgs: null }
141
+ }
142
+ case 'collapsible': {
143
+ if (message.thinking === true && !message.collapsed) {
144
+ const streaming = message.running === true || message.streaming === true
145
+ const rendered = streaming
146
+ ? renderMarkdownStreaming(message.body, inner, true, `thinking:${message.id}`)
147
+ : renderMarkdown(message.body, inner, true, `thinking:${message.id}`)
148
+ return { lines: rendered.lines, rows: rendered.rows, bgs: null }
149
+ }
150
+ const lines = wrapLines(message.body, inner)
151
+ return { lines, rows: null, bgs: null }
152
+ }
153
+ case 'tool-card': {
154
+ if (message.diff !== undefined && message.diff.hunks.length > 0) {
155
+ const rendered = renderToolDiffBody({
156
+ tool: message.tool,
157
+ path: message.diff.path,
158
+ hunks: message.diff.hunks,
159
+ error: message.error,
160
+ ...(message.diff.backgrounds === undefined ? {} : { backgrounds: message.diff.backgrounds }),
161
+ }, inner)
162
+ return { lines: rendered.lines, rows: rendered.rows, bgs: rendered.bgs }
163
+ }
164
+ if (message.read !== undefined) {
165
+ const rendered = renderToolReadBody({
166
+ ...(message.read.path === undefined ? {} : { path: message.read.path }),
167
+ lines: message.read.lines,
168
+ ...(message.read.offset === undefined ? {} : { offset: message.read.offset }),
169
+ ...(message.read.totalLines === undefined ? {} : { totalLines: message.read.totalLines }),
170
+ ...(message.read.lang === undefined ? {} : { lang: message.read.lang }),
171
+ }, inner)
172
+ return { lines: rendered.lines, rows: rendered.rows, bgs: rendered.bgs }
173
+ }
174
+ const lines: string[] = []
175
+ const rows: Segment[][] = []
176
+ const push = (text: string, segments?: Segment[]): void => {
177
+ lines.push(text)
178
+ if (segments !== undefined) rows[lines.length - 1] = segments
179
+ }
180
+ const hasError = message.error !== undefined && message.error !== ''
181
+ // `failed` is a render state, not content: the result text of an
182
+ // errored call IS the error message, so it renders in red instead of
183
+ // gaining a synthetic "failed" line next to it.
184
+ const failed = message.failed === true && !hasError
185
+ const resultBody = message.resultBody !== undefined && message.resultBody.trimEnd() !== ''
186
+ ? trimTrailingBlanks(message.resultBody)
187
+ : undefined
188
+ const pill = message.exitCode !== undefined
189
+ ? `[exit code: ${message.exitCode}]`
190
+ : message.signal !== undefined ? `[killed by signal: ${message.signal}]` : undefined
191
+ const hasArgs = message.argsBody !== ''
192
+ const hasNested = (message.nested?.length ?? 0) > 0
193
+ if (!hasArgs && resultBody === undefined && pill === undefined && !hasNested && !hasError && !failed) {
194
+ return { lines: [], rows: null, bgs: null }
195
+ }
196
+ if (hasArgs) {
197
+ for (const line of wrapLines(message.argsBody, inner)) push(line)
198
+ }
199
+ if (resultBody !== undefined) {
200
+ if (hasArgs) push('')
201
+ for (const line of wrapLines(resultBody, inner)) {
202
+ push(line, failed ? [{ text: line, style: { color: COLORS.errorText } }] : undefined)
203
+ }
204
+ } else if (failed) {
205
+ if (hasArgs) push('')
206
+ push('failed', [{ text: 'failed', style: { color: COLORS.errorText } }])
207
+ }
208
+ if (pill !== undefined) push(pill)
209
+ if (hasNested) {
210
+ const prefix = `${glyphs.treeBranch} `
211
+ for (const child of message.nested!) {
212
+ const state = child.running
213
+ ? ''
214
+ : child.error !== undefined
215
+ ? ` ${glyphs.separator} error`
216
+ : child.resultBody === undefined || child.resultBody === '' ? '' : ` ${glyphs.separator} ${firstLine(child.resultBody)}`
217
+ const label = truncate(child.label, Math.max(4, inner - 6))
218
+ push(
219
+ `${prefix}${label}${state}`,
220
+ [
221
+ { text: prefix, style: { color: COLORS.toolBodyText } },
222
+ { text: `${label}${state}`, style: {} },
223
+ ],
224
+ )
225
+ }
226
+ }
227
+ if (hasError) {
228
+ if (lines.length > 0 && lines[lines.length - 1] !== '') push('')
229
+ const text = truncate(message.error!, Math.max(8, inner))
230
+ push(text, [{ text, style: { color: COLORS.errorText } }])
231
+ }
232
+ return { lines, rows: rows.length === 0 ? null : rows, bgs: null }
233
+ }
234
+ case 'compaction': {
235
+ if (message.summary === '' && message.error === undefined) return { lines: [], rows: null, bgs: null }
236
+ const streaming = message.running === true && message.summary !== ''
237
+ const rendered = message.summary === ''
238
+ ? { lines: [], rows: [] }
239
+ : streaming
240
+ ? renderMarkdownStreaming(message.summary, inner, false, `compaction:${message.id}`)
241
+ : renderMarkdown(message.summary, inner, false, `compaction:${message.id}`)
242
+ const lines = [...rendered.lines]
243
+ const rows = [...rendered.rows]
244
+ if (message.error !== undefined && message.error !== '') {
245
+ const text = truncate(`error: ${message.error}`, Math.max(8, inner))
246
+ lines.push(text)
247
+ rows.push([{ text, style: { color: COLORS.errorText } }])
248
+ }
249
+ return { lines, rows, bgs: null }
250
+ }
251
+ case 'home-logo': {
252
+ const lines: string[] = []
253
+ const rows: Segment[][] = []
254
+ message.lines.forEach((line, i) => {
255
+ lines.push(line)
256
+ rows.push([{ text: line, style: { color: message.colors[i] } }])
257
+ })
258
+ for (const line of message.info) lines.push(truncate(line, Math.max(8, inner)))
259
+ return { lines, rows: rows.length === 0 ? null : rows, bgs: null }
260
+ }
261
+ case 'custom': {
262
+ const view = messageViewOf(message.view)
263
+ if (view === undefined) {
264
+ return { lines: wrapLines(describeCustomData(message.data), inner), rows: null, bgs: null }
265
+ }
266
+ const rendered = view.render({ message, width: inner })
267
+ const lines = rendered.wrap === false
268
+ ? [...rendered.lines]
269
+ : rendered.lines.flatMap(line => wrapLines(line, inner))
270
+ return {
271
+ lines,
272
+ rows: null,
273
+ bgs: null,
274
+ customLabel: rendered.label ?? message.view,
275
+ customMuted: rendered.muted ?? true,
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ function describeCustomData(data: unknown): string {
282
+ if (typeof data === 'string') return data
283
+ try {
284
+ return JSON.stringify(data, null, 2) ?? ''
285
+ } catch {
286
+ return String(data)
287
+ }
288
+ }
289
+
290
+ function firstLine(text: string): string {
291
+ const at = text.indexOf('\n')
292
+ return at === -1 ? text : text.slice(0, at)
293
+ }
294
+
295
+ function planFor(message: Message, body: BodyRendered): PlanRow[] {
296
+ switch (message.kind) {
297
+ case 'bubble': {
298
+ // User bubbles sit inside the background box; assistant, error, and
299
+ // command output float without it.
300
+ const floating = message.role === 'error' || message.role === 'assistant' || message.origin === 'command'
301
+ const texts = bodyRows(body.lines.length, {
302
+ colStart: 4,
303
+ bg: !floating,
304
+ muted: false,
305
+ selectable: true,
306
+ role: message.role,
307
+ })
308
+ return floating ? [...texts, blankRow()] : [padRow(message.role), ...texts, padRow(message.role), blankRow()]
309
+ }
310
+ case 'collapsible': {
311
+ const header: PlanRow = {
312
+ type: 'header',
313
+ label: message.label,
314
+ collapsed: message.collapsed,
315
+ clickable: !Boolean(message.streaming),
316
+ }
317
+ if (message.collapsed) return [header, blankRow()]
318
+ return [
319
+ header,
320
+ blankRow(),
321
+ ...bodyRows(body.lines.length, { colStart: HEADER_LABEL_COL, bg: false, muted: true, selectable: true, role: 'assistant' }),
322
+ blankRow(),
323
+ ]
324
+ }
325
+ case 'tool-card': {
326
+ const header: PlanRow = {
327
+ type: 'lit',
328
+ text: message.label,
329
+ colStart: 4,
330
+ bg: true,
331
+ muted: true,
332
+ selectable: true,
333
+ role: 'assistant',
334
+ ...(message.streaming === true || message.running === true ? { wave: true } : {}),
335
+ }
336
+ if (body.lines.length === 0 && message.running === true) {
337
+ return [padRow('assistant'), header, padRow('assistant'), blankRow()]
338
+ }
339
+ return [
340
+ padRow('assistant'),
341
+ header,
342
+ padRow('assistant'),
343
+ ...bodyRows(body.lines.length, { colStart: 4, bg: true, muted: false, selectable: true, role: 'assistant' }),
344
+ padRow('assistant'),
345
+ blankRow(),
346
+ ]
347
+ }
348
+ case 'compaction': {
349
+ const header: PlanRow = {
350
+ type: 'lit',
351
+ text: 'Compact',
352
+ segments: [{ text: 'Compact', style: { color: COLORS.sectionHeader, bold: true } }],
353
+ colStart: 4,
354
+ bg: true,
355
+ muted: false,
356
+ selectable: false,
357
+ role: 'assistant',
358
+ ...(message.running ? { wave: true } : {}),
359
+ }
360
+ if (message.running) {
361
+ return [padRow('assistant'), header, padRow('assistant'), blankRow()]
362
+ }
363
+ return [
364
+ padRow('assistant'),
365
+ header,
366
+ padRow('assistant'),
367
+ ...bodyRows(body.lines.length, { colStart: 4, bg: true, muted: false, selectable: true, role: 'assistant' }),
368
+ padRow('assistant'),
369
+ blankRow(),
370
+ ]
371
+ }
372
+ case 'home-logo':
373
+ return [
374
+ ...bodyRows(body.lines.length, { colStart: 4, bg: false, muted: false, selectable: true, role: 'assistant' }),
375
+ blankRow(),
376
+ ]
377
+ case 'custom': {
378
+ const header: PlanRow = {
379
+ type: 'header',
380
+ label: body.customLabel ?? message.view,
381
+ collapsed: false,
382
+ clickable: false,
383
+ }
384
+ return [
385
+ header,
386
+ blankRow(),
387
+ ...bodyRows(body.lines.length, {
388
+ colStart: HEADER_LABEL_COL,
389
+ bg: false,
390
+ muted: body.customMuted ?? true,
391
+ selectable: true,
392
+ role: 'assistant',
393
+ }),
394
+ blankRow(),
395
+ ]
396
+ }
397
+ }
398
+ }
399
+
400
+ type RenderFingerprint =
401
+ | { kind: 'bubble'; content: string; role: string | undefined; origin: string | undefined; streaming: boolean | undefined }
402
+ | { kind: 'collapsible'; body: string; label: string; collapsed: boolean; running: boolean; thinking: boolean | undefined; streaming: boolean | undefined }
403
+ | { kind: 'tool-card'; tool: string; label: string; argsBody: string; resultBody: string | undefined; diff: ToolCardMessage['diff']; read: ToolCardMessage['read']; nested: ToolCardMessage['nested']; error: string | undefined; failed: boolean | undefined; exitCode: number | undefined; signal: string | undefined; bodyCol: number | undefined; running: boolean; streaming: boolean | undefined }
404
+ | { kind: 'compaction'; summary: string; running: boolean; error: string | undefined }
405
+ | { kind: 'custom'; view: string; data: unknown; running: boolean | undefined; streaming: boolean | undefined }
406
+ | { kind: 'home-logo'; lines: readonly string[]; colors: readonly string[]; info: readonly string[] }
407
+
408
+ interface RenderMemoEntry {
409
+ epoch: number
410
+ width: number
411
+ fingerprint: RenderFingerprint
412
+ render: MessageRender
413
+ }
414
+
415
+ const renderMemo = new WeakMap<Message, RenderMemoEntry>()
416
+
417
+ function fingerprintOf(message: Message): RenderFingerprint {
418
+ switch (message.kind) {
419
+ case 'bubble':
420
+ return {
421
+ kind: 'bubble',
422
+ content: message.content,
423
+ role: message.role,
424
+ origin: message.origin,
425
+ streaming: message.streaming,
426
+ }
427
+ case 'collapsible':
428
+ return {
429
+ kind: 'collapsible',
430
+ body: message.body,
431
+ label: message.label,
432
+ collapsed: message.collapsed,
433
+ running: message.running,
434
+ thinking: message.thinking,
435
+ streaming: message.streaming,
436
+ }
437
+ case 'tool-card':
438
+ return {
439
+ kind: 'tool-card',
440
+ tool: message.tool,
441
+ label: message.label,
442
+ argsBody: message.argsBody,
443
+ resultBody: message.resultBody,
444
+ diff: message.diff,
445
+ read: message.read,
446
+ nested: message.nested,
447
+ error: message.error,
448
+ failed: message.failed,
449
+ exitCode: message.exitCode,
450
+ signal: message.signal,
451
+ bodyCol: message.bodyCol,
452
+ running: message.running,
453
+ streaming: message.streaming,
454
+ }
455
+ case 'compaction':
456
+ return {
457
+ kind: 'compaction',
458
+ summary: message.summary,
459
+ running: message.running,
460
+ error: message.error,
461
+ }
462
+ case 'custom':
463
+ return {
464
+ kind: 'custom',
465
+ view: message.view,
466
+ data: message.data,
467
+ running: message.running,
468
+ streaming: message.streaming,
469
+ }
470
+ case 'home-logo':
471
+ return {
472
+ kind: 'home-logo',
473
+ lines: message.lines,
474
+ colors: message.colors,
475
+ info: message.info,
476
+ }
477
+ }
478
+ }
479
+
480
+ function fingerprintMatches(entry: RenderFingerprint, message: Message): boolean {
481
+ switch (entry.kind) {
482
+ case 'bubble':
483
+ return message.kind === 'bubble'
484
+ && entry.content === message.content
485
+ && entry.role === message.role
486
+ && entry.origin === message.origin
487
+ && entry.streaming === message.streaming
488
+ case 'collapsible':
489
+ return message.kind === 'collapsible'
490
+ && entry.body === message.body
491
+ && entry.label === message.label
492
+ && entry.collapsed === message.collapsed
493
+ && entry.running === message.running
494
+ && entry.thinking === message.thinking
495
+ && entry.streaming === message.streaming
496
+ case 'tool-card':
497
+ return message.kind === 'tool-card'
498
+ && entry.tool === message.tool
499
+ && entry.label === message.label
500
+ && entry.argsBody === message.argsBody
501
+ && entry.resultBody === message.resultBody
502
+ && entry.diff === message.diff
503
+ && entry.read === message.read
504
+ && entry.nested === message.nested
505
+ && entry.error === message.error
506
+ && entry.failed === message.failed
507
+ && entry.exitCode === message.exitCode
508
+ && entry.signal === message.signal
509
+ && entry.bodyCol === message.bodyCol
510
+ && entry.running === message.running
511
+ && entry.streaming === message.streaming
512
+ case 'compaction':
513
+ return message.kind === 'compaction'
514
+ && entry.summary === message.summary
515
+ && entry.running === message.running
516
+ && entry.error === message.error
517
+ case 'custom':
518
+ return message.kind === 'custom'
519
+ && entry.view === message.view
520
+ && entry.data === message.data
521
+ && entry.running === message.running
522
+ && entry.streaming === message.streaming
523
+ case 'home-logo':
524
+ return message.kind === 'home-logo'
525
+ && entry.lines === message.lines
526
+ && entry.colors === message.colors
527
+ && entry.info === message.info
528
+ }
529
+ }
530
+
531
+ function renderFor(message: Message, width: number): MessageRender {
532
+ const memo = renderMemo.get(message)
533
+ if (memo !== undefined && memo.epoch === layoutEpoch && memo.width === width && fingerprintMatches(memo.fingerprint, message)) {
534
+ return memo.render
535
+ }
536
+ const body = renderBody(message, width)
537
+ const render: MessageRender = { ...body, plan: planFor(message, body) }
538
+ renderMemo.set(message, { epoch: layoutEpoch, width, fingerprint: fingerprintOf(message), render })
539
+ return render
540
+ }
541
+
542
+ export function lineCount(message: Message, width: number): number {
543
+ return renderFor(message, width).plan.length
544
+ }
545
+
546
+ export interface RowIndex {
547
+ total: number
548
+ rowAt(row: number): RowInfo | null
549
+ }
550
+
551
+ export function buildRowIndex(messages: Message[], width: number): RowIndex {
552
+ const starts = new Array<number>(messages.length)
553
+ const counts = new Array<number>(messages.length)
554
+ let total = 0
555
+ for (let i = 0; i < messages.length; i++) {
556
+ starts[i] = total
557
+ const count = lineCount(messages[i]!, width)
558
+ counts[i] = count
559
+ total += count
560
+ }
561
+ return {
562
+ total,
563
+ rowAt(row: number): RowInfo | null {
564
+ if (row < 0 || row >= total) return null
565
+ let low = 0
566
+ let high = messages.length - 1
567
+ while (low < high) {
568
+ const mid = (low + high + 1) >> 1
569
+ if (starts[mid]! <= row) low = mid
570
+ else high = mid - 1
571
+ }
572
+ return rowInfo(messages[low]!, low, row - starts[low]!, width)
573
+ },
574
+ }
575
+ }
576
+
577
+ export function rowCount(messages: Message[], width: number): number {
578
+ return rowIndexFor(messages, width).total
579
+ }
580
+
581
+ let indexMessages: Message[] | undefined
582
+ let indexWidth = -1
583
+ let indexCache: RowIndex | undefined
584
+
585
+ export function rowIndexFor(messages: Message[], width: number): RowIndex {
586
+ if (indexCache !== undefined && indexMessages === messages && indexWidth === width) {
587
+ return indexCache
588
+ }
589
+ indexMessages = messages
590
+ indexWidth = width
591
+ indexCache = buildRowIndex(messages, width)
592
+ return indexCache
593
+ }
594
+
595
+ export function rowInfoAt(messages: Message[], width: number, row: number): RowInfo | null {
596
+ return rowIndexFor(messages, width).rowAt(row)
597
+ }
598
+
599
+ const segKeyMemo = new WeakMap<Segment[], string>()
600
+
601
+ function segmentsKeyCached(segments: Segment[]): string {
602
+ const hit = segKeyMemo.get(segments)
603
+ if (hit !== undefined) return hit
604
+ const key = segmentsKey(segments)
605
+ segKeyMemo.set(segments, key)
606
+ return key
607
+ }
608
+
609
+ function rowInfo(message: Message, index: number, offset: number, width: number): RowInfo {
610
+ const render = renderFor(message, width)
611
+ const plan = render.plan[offset]
612
+ const base: RowInfo = {
613
+ messageIndex: index,
614
+ messageId: message.id,
615
+ lineNo: offset,
616
+ kind: 'blank',
617
+ text: '',
618
+ colStart: 0,
619
+ selectable: false,
620
+ clickable: false,
621
+ background: false,
622
+ backgroundWidth: messageBackgroundWidth(width),
623
+ muted: false,
624
+ label: '',
625
+ collapsed: false,
626
+ thinking: false,
627
+ role: undefined,
628
+ }
629
+ if (plan === undefined) return base
630
+ switch (plan.type) {
631
+ case 'pad':
632
+ return { ...base, kind: 'pad', background: true, role: plan.role }
633
+ case 'blank':
634
+ return base
635
+ case 'header':
636
+ return {
637
+ ...base,
638
+ kind: 'header',
639
+ colStart: HEADER_LABEL_COL,
640
+ clickable: plan.clickable,
641
+ label: fitLabel(plan.label, width),
642
+ collapsed: plan.collapsed,
643
+ spinner: Boolean(
644
+ (message.kind === 'collapsible' || message.kind === 'custom')
645
+ && (message.running || message.streaming),
646
+ ) || undefined,
647
+ }
648
+ case 'lit':
649
+ return {
650
+ ...base,
651
+ kind: 'text',
652
+ text: fitLabel(plan.text, width),
653
+ colStart: plan.colStart,
654
+ selectable: plan.selectable,
655
+ background: plan.bg,
656
+ muted: plan.muted,
657
+ role: plan.role,
658
+ ...(plan.wave === true ? { wave: true } : {}),
659
+ ...(plan.segments === undefined ? {} : { segments: plan.segments, segKey: segmentsKeyCached(plan.segments) }),
660
+ }
661
+ case 'body': {
662
+ const lineBg = render.bgs?.[plan.line]
663
+ const segments = render.rows?.[plan.line]
664
+ return {
665
+ ...base,
666
+ kind: 'text',
667
+ text: render.lines[plan.line] ?? '',
668
+ colStart: plan.colStart,
669
+ selectable: plan.selectable,
670
+ background: plan.bg || lineBg !== undefined,
671
+ muted: plan.muted,
672
+ role: plan.role,
673
+ ...(plan.wave === true ? { wave: true } : {}),
674
+ ...(lineBg === undefined ? {} : { lineBg }),
675
+ ...(segments === undefined ? {} : { segments, segKey: segmentsKeyCached(segments) }),
676
+ }
677
+ }
678
+ }
679
+ }
680
+
681
+ export function fitLabel(label: string, width: number): string {
682
+ const available = Math.max(1, width - 8)
683
+ if (textWidth(label) <= available) return label
684
+ const bracket = label.indexOf('[')
685
+ if (bracket < 0) {
686
+ const cut = truncate(label, Math.max(1, available - 3))
687
+ return cut.length === label.length ? cut : `${cut}...`
688
+ }
689
+ const name = label.slice(0, bracket + 1)
690
+ const rest = label.slice(bracket + 1)
691
+ const budget = Math.max(1, available - textWidth(name) - 4)
692
+ const cut = truncate(rest, budget)
693
+ return `${name}${cut.length === rest.length ? rest : `${cut}...`}]`
694
+ }
695
+
696
+ export function headerSymbol(collapsed: boolean): string {
697
+ return collapsed ? glyphs.headerCollapsed : glyphs.headerExpanded
698
+ }
699
+
700
+ export interface ScrollbarGeometry {
701
+ top: number
702
+ height: number
703
+ }
704
+
705
+ export function scrollbarGeometry(total: number, height: number, scrollTop: number): ScrollbarGeometry | null {
706
+ if (total <= height || height < 1) return null
707
+ const thumbHeight = Math.max(1, Math.floor((height * height) / total))
708
+ const maxScroll = total - height
709
+ const travel = height - thumbHeight
710
+ const top = maxScroll === 0 ? 0 : Math.min(travel, Math.round((travel * scrollTop) / maxScroll))
711
+ return { top, height: thumbHeight }
712
+ }
713
+
714
+ export function selectionText(messages: Message[], width: number, selection: LineSelection): string {
715
+ const index = rowIndexFor(messages, width)
716
+ const total = index.total
717
+ const top = Math.max(0, Math.min(selection.anchorRow, selection.focusRow))
718
+ const bottom = Math.min(Math.max(selection.anchorRow, selection.focusRow), total - 1)
719
+ const envStart = Math.min(selection.anchorCol, selection.focusCol)
720
+ const envEnd = Math.max(selection.anchorCol, selection.focusCol)
721
+ const lines: string[] = []
722
+ for (let row = top; row <= bottom; row++) {
723
+ const info = index.rowAt(row)
724
+ if (info === null) continue
725
+ if (info.kind === 'pad') continue
726
+ const range = selectedRange(selection, row)
727
+ if (range === null) continue
728
+ if (info.kind === 'header') {
729
+ let line = ''
730
+ let prevEnd = -1
731
+ const pieces: Array<{ text: string; col: number }> = [
732
+ { text: ` ${headerSymbol(info.collapsed)} `, col: 0 },
733
+ { text: info.label, col: HEADER_LABEL_COL },
734
+ ]
735
+ for (const piece of pieces) {
736
+ const pieceWidth = textWidth(piece.text)
737
+ if (!(envEnd > piece.col && envStart < piece.col + pieceWidth)) continue
738
+ const start = Math.max(range.start, piece.col)
739
+ const end = Math.min(range.end, piece.col + pieceWidth)
740
+ if (start >= end) continue
741
+ const text = sliceByColumns(piece.text, start - piece.col, end - piece.col)
742
+ if (text === '') continue
743
+ if (line !== '') line += prevEnd === piece.col ? '' : ' '
744
+ line += text
745
+ prevEnd = piece.col + pieceWidth
746
+ }
747
+ lines.push(line)
748
+ continue
749
+ }
750
+ const line = info.text
751
+ const lineWidth = textWidth(line)
752
+ const left = Math.max(range.start, info.colStart)
753
+ const right = Math.min(range.end, info.colStart + lineWidth)
754
+ if (left < right) {
755
+ const startIndex = colToCharIndex(line, left - info.colStart)
756
+ const endIndex = colToCharIndex(line, right - info.colStart)
757
+ lines.push(expandFieldChars(line.slice(startIndex, endIndex)))
758
+ continue
759
+ }
760
+ lines.push('')
761
+ }
762
+ return lines.join('\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '')
763
+ }