@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,97 @@
1
+ import type { Token, Tokens } from 'marked'
2
+ import { mergeRuns } from '../../../core/segments.ts'
3
+ import type { MarkStyle, Segment } from '../../../core/segments.ts'
4
+ import { markdownExtensions } from './extensions.ts'
5
+ import type { MdPalette } from './palette.ts'
6
+
7
+ function mergeStyle(base: MarkStyle | undefined, extra: MarkStyle): MarkStyle {
8
+ const merged: MarkStyle = {}
9
+ const color = extra.color ?? base?.color
10
+ if (color !== undefined) merged.color = color
11
+ if (base?.bold === true || extra.bold === true) merged.bold = true
12
+ if (base?.italic === true || extra.italic === true) merged.italic = true
13
+ if (base?.strike === true || extra.strike === true) merged.strike = true
14
+ if (base?.underline === true || extra.underline === true) merged.underline = true
15
+ return merged
16
+ }
17
+
18
+ export function renderInline(tokens: Token[], palette: MdPalette, base?: MarkStyle): Segment[] {
19
+ const out: Segment[] = []
20
+ const emit = (text: string, style: MarkStyle): void => {
21
+ if (text !== '') out.push({ text, style })
22
+ }
23
+ for (const token of tokens) {
24
+ const contributed = markdownExtensions.markdownInlineOf(token)
25
+ if (contributed !== undefined) {
26
+ out.push(contributed.render(token, { palette, base }))
27
+ continue
28
+ }
29
+ switch (token.type) {
30
+ case 'strong': {
31
+ const t = token as Tokens.Strong
32
+ out.push(...renderInline(t.tokens, palette, mergeStyle(base, palette.bold)))
33
+ break
34
+ }
35
+ case 'em': {
36
+ const t = token as Tokens.Em
37
+ out.push(...renderInline(t.tokens, palette, mergeStyle(base, palette.italic)))
38
+ break
39
+ }
40
+ case 'del': {
41
+ const t = token as Tokens.Del
42
+ out.push(...renderInline(t.tokens, palette, mergeStyle(base, palette.strike)))
43
+ break
44
+ }
45
+ case 'codespan': {
46
+ const t = token as Tokens.Codespan
47
+ emit(t.text.replace(/\n/gm, ' '), mergeStyle(base, palette.inlineCode))
48
+ break
49
+ }
50
+ case 'link': {
51
+ const t = token as Tokens.Link
52
+ out.push(...renderInline(t.tokens.length > 0 ? t.tokens : [{ type: 'text', raw: t.text, text: t.text }], palette, mergeStyle(base, palette.link)))
53
+ break
54
+ }
55
+ case 'image': {
56
+ const t = token as Tokens.Image
57
+ emit(t.text, base ?? palette.plain)
58
+ break
59
+ }
60
+ case 'br': {
61
+ emit('\n', palette.plain)
62
+ break
63
+ }
64
+ case 'escape': {
65
+ const t = token as Tokens.Escape
66
+ emit(t.text, base ?? palette.plain)
67
+ break
68
+ }
69
+ case 'inline-links':
70
+ case 'url': {
71
+ const t = token as Tokens.Generic & { href: string; text: string }
72
+ emit(t.text !== '' ? t.text : t.href, mergeStyle(base, palette.link))
73
+ break
74
+ }
75
+ case 'html': {
76
+ const t = token as Tokens.HTML
77
+ emit(t.raw.replace(/<br\s*\/?>/gi, '\n'), base ?? palette.plain)
78
+ break
79
+ }
80
+ case 'text': {
81
+ const t = token as Tokens.Text
82
+ if (t.tokens !== undefined && t.tokens.length > 0) {
83
+ out.push(...renderInline(t.tokens, palette, base))
84
+ } else {
85
+ emit(t.text, base ?? palette.plain)
86
+ }
87
+ break
88
+ }
89
+ default: {
90
+ const raw = (token as Tokens.Generic).raw
91
+ if (typeof raw === 'string') emit(raw, base ?? palette.plain)
92
+ break
93
+ }
94
+ }
95
+ }
96
+ return mergeRuns(out)
97
+ }
@@ -0,0 +1,87 @@
1
+ import type { MarkStyle } from '../../../core/segments.ts'
2
+ import { COLORS } from '../../../theme.ts'
3
+
4
+ export interface MdPalette {
5
+ plain: MarkStyle
6
+ bold: MarkStyle
7
+ italic: MarkStyle
8
+ strike: MarkStyle
9
+ link: MarkStyle
10
+ inlineCode: MarkStyle
11
+ quoteBar: MarkStyle
12
+ hr: MarkStyle
13
+ listMarker: MarkStyle
14
+ taskDone: MarkStyle
15
+ taskTodo: MarkStyle
16
+ heading(level: number): MarkStyle
17
+ codePlain: MarkStyle
18
+ codeFallback: MarkStyle
19
+ }
20
+
21
+ interface ColorSpec {
22
+ link: string
23
+ inlineCode: string
24
+ quoteBar: string
25
+ hr: string
26
+ list: string
27
+ taskDone: string
28
+ taskTodo: string
29
+ h1: string
30
+ h3: string
31
+ codePlain: string
32
+ codeFallback: string
33
+ }
34
+
35
+ const MD_SPEC: ColorSpec = {
36
+ link: 'mdLink',
37
+ inlineCode: 'mdInlineCode',
38
+ quoteBar: 'mdQuoteBar',
39
+ hr: 'mdHr',
40
+ list: 'mdList',
41
+ taskDone: 'mdTaskDone',
42
+ taskTodo: 'mdTaskTodo',
43
+ h1: 'mdH1',
44
+ h3: 'mdH3',
45
+ codePlain: 'mdCodePlain',
46
+ codeFallback: 'mdCodeFallback',
47
+ }
48
+
49
+ const THINK_SPEC: ColorSpec = {
50
+ link: 'thinkLink',
51
+ inlineCode: 'thinkInlineCode',
52
+ quoteBar: 'mdQuoteBar',
53
+ hr: 'thinkHr',
54
+ list: 'thinkList',
55
+ taskDone: 'thinkTaskDone',
56
+ taskTodo: 'thinkTaskTodo',
57
+ h1: 'thinkH1',
58
+ h3: 'thinkH1',
59
+ codePlain: 'thinkCodePlain',
60
+ codeFallback: 'thinkCodeFallback',
61
+ }
62
+
63
+ function buildPalette(spec: ColorSpec): MdPalette {
64
+ const at = (key: string): MarkStyle => ({ color: (COLORS as unknown as Record<string, string>)[key] })
65
+ return {
66
+ plain: {},
67
+ bold: { bold: true },
68
+ italic: { italic: true },
69
+ strike: { strike: true },
70
+ link: { ...at(spec.link), underline: true },
71
+ inlineCode: at(spec.inlineCode),
72
+ quoteBar: at(spec.quoteBar),
73
+ hr: at(spec.hr),
74
+ listMarker: at(spec.list),
75
+ taskDone: at(spec.taskDone),
76
+ taskTodo: at(spec.taskTodo),
77
+ heading(level) {
78
+ return { color: (COLORS as unknown as Record<string, string>)[level <= 2 ? spec.h1 : spec.h3], bold: true }
79
+ },
80
+ codePlain: at(spec.codePlain),
81
+ codeFallback: at(spec.codeFallback),
82
+ }
83
+ }
84
+
85
+ export function createMdPalette(thinking: boolean): MdPalette {
86
+ return buildPalette(thinking ? THINK_SPEC : MD_SPEC)
87
+ }
@@ -0,0 +1,69 @@
1
+ import { Box, useInput } from 'ink'
2
+ import { isKeyConsumed } from '../key-arbiter.ts'
3
+ import type { Message } from '../../model/message.ts'
4
+ import { rowIndexFor, scrollbarGeometry } from './layout.ts'
5
+ import { MessageRow } from './message-row.tsx'
6
+ import { COLORS } from '../../theme.ts'
7
+ import { scrollbarColumn } from '../layout-service.ts'
8
+ import { Region } from '../region.tsx'
9
+
10
+ interface MessageListProps {
11
+ messages: Message[]
12
+ height: number
13
+ width: number
14
+ scrollTop: number
15
+ onScroll: (next: number) => void
16
+ interactive?: boolean
17
+ themeTick?: number
18
+ }
19
+
20
+ export function MessageList({ messages, height, width, scrollTop, onScroll, interactive = true, themeTick = 0 }: MessageListProps) {
21
+ const index = rowIndexFor(messages, width)
22
+ const total = index.total
23
+ const halfPage = Math.max(1, Math.ceil(height / 2))
24
+ useInput((input, key) => {
25
+ if (!interactive || isKeyConsumed()) return
26
+ const maxScroll = Math.max(0, total - height)
27
+ const scroll = (delta: number): void => {
28
+ onScroll(Math.max(0, Math.min(maxScroll, scrollTop + delta)))
29
+ }
30
+ if (key.pageUp) scroll(-(height - 2))
31
+ if (key.pageDown) scroll(height - 2)
32
+ if (key.ctrl && input === 'u') scroll(-halfPage)
33
+ if (key.ctrl && input === 'd') scroll(halfPage)
34
+ })
35
+ const rows = []
36
+ const endRow = Math.min(scrollTop + height, total)
37
+ for (let row = scrollTop; row < endRow; row++) {
38
+ const info = index.rowAt(row)
39
+ if (info) rows.push(<MessageRow key={`${info.messageId}:${info.lineNo}`} info={info} row={row} themeTick={themeTick} />)
40
+ }
41
+ const scrollbar = scrollbarGeometry(total, height, scrollTop)
42
+ return (
43
+ <Box width={width} height={height} flexDirection="column" overflow="hidden">
44
+ <Region y={-scrollTop}>
45
+ {rows}
46
+ </Region>
47
+ {scrollbar !== null && (
48
+ <>
49
+ <Box
50
+ position="absolute"
51
+ top={0}
52
+ left={scrollbarColumn(width)}
53
+ width={1}
54
+ height={height}
55
+ backgroundColor={COLORS.scrollTrackBackground}
56
+ />
57
+ <Box
58
+ position="absolute"
59
+ top={scrollbar.top}
60
+ left={scrollbarColumn(width)}
61
+ width={1}
62
+ height={scrollbar.height}
63
+ backgroundColor={COLORS.scrollThumbBackground}
64
+ />
65
+ </>
66
+ )}
67
+ </Box>
68
+ )
69
+ }
@@ -0,0 +1,137 @@
1
+ import { Box, Text } from 'ink'
2
+ import { memo } from 'react'
3
+ import { COLORS } from '../../theme.ts'
4
+ import { glyphs } from '../../terminal/glyphs.ts'
5
+ import { headerSymbol, HEADER_LABEL_COL } from './layout.ts'
6
+ import type { RowInfo } from './layout.ts'
7
+ import { SelectableText } from '../selection.tsx'
8
+ import { useSpinnerTick } from '../spinner-tick.tsx'
9
+ import { wavePalette, waveSegments } from './wave.ts'
10
+
11
+ function TickGlyph({ y, col, color }: { y: number; col: number; color?: string }): ReturnType<typeof SelectableText> {
12
+ const tick = useSpinnerTick()
13
+ return (
14
+ <SelectableText
15
+ y={y}
16
+ col={col}
17
+ text={`${glyphs.spinnerFrames[tick % glyphs.spinnerFrames.length]} `}
18
+ color={color}
19
+ messageLayer
20
+ />
21
+ )
22
+ }
23
+
24
+ function WaveText({ info, row, color }: { info: RowInfo; row: number; color: string }): ReturnType<typeof SelectableText> {
25
+ const tick = useSpinnerTick()
26
+ const base = info.segments?.[0]?.style.bold === true ? { bold: true } : {}
27
+ return (
28
+ <SelectableText
29
+ y={row}
30
+ col={info.colStart}
31
+ segments={waveSegments(info.text, tick, wavePalette(color), color, base)}
32
+ messageLayer
33
+ flow
34
+ />
35
+ )
36
+ }
37
+
38
+ interface MessageRowProps {
39
+ info: RowInfo
40
+ row: number
41
+ themeTick?: number
42
+ }
43
+
44
+ // The row component itself consumes no context: only the spinner and wave
45
+ // subcomponents subscribe to the tick, so memoized rows that are not
46
+ // animating stay untouched on every tick.
47
+ export const MessageRow = memo(
48
+ function MessageRow({ info, row, themeTick = 0 }: MessageRowProps) {
49
+ switch (info.kind) {
50
+ case 'pad':
51
+ return (
52
+ <Box marginLeft={2} width={info.backgroundWidth} backgroundColor={backgroundFor(info.role)}>
53
+ <Text>{' '.repeat(info.backgroundWidth)}</Text>
54
+ </Box>
55
+ )
56
+ case 'text': {
57
+ const col = info.colStart
58
+ const marginLeft = col >= 2 ? 2 : col
59
+ const paddingLeft = col >= 2 ? col - 2 : 0
60
+ const baseColor = info.role === 'error' ? COLORS.errorText : info.muted ? COLORS.toolBodyText : undefined
61
+ const waveColor = info.segments?.[0]?.style.color ?? baseColor
62
+ return (
63
+ <Box
64
+ marginLeft={marginLeft}
65
+ width={info.backgroundWidth}
66
+ paddingLeft={info.spinner ? 0 : paddingLeft}
67
+ backgroundColor={info.lineBg ?? (info.background ? backgroundFor(info.role) : undefined)}
68
+ >
69
+ {info.spinner && <TickGlyph y={row} col={col - 2} color={info.accent ?? baseColor} />}
70
+ {info.wave && waveColor !== undefined ? (
71
+ <WaveText info={info} row={row} color={waveColor} />
72
+ ) : info.segments !== undefined && info.segments.length > 0 ? (
73
+ <SelectableText y={row} col={col} segments={info.segments} color={baseColor} messageLayer flow />
74
+ ) : (
75
+ <SelectableText
76
+ text={info.text || ' '}
77
+ y={row}
78
+ col={col}
79
+ color={baseColor}
80
+ messageLayer
81
+ flow
82
+ />
83
+ )}
84
+ </Box>
85
+ )
86
+ }
87
+ case 'header': {
88
+ const symbol = headerSymbol(info.collapsed)
89
+ const color = COLORS.toolLabel
90
+ return (
91
+ <Box>
92
+ {info.spinner ? (
93
+ <>
94
+ <SelectableText y={row} col={0} text={' '} color={color} messageLayer />
95
+ <TickGlyph y={row} col={2} color={color} />
96
+ </>
97
+ ) : (
98
+ <SelectableText y={row} col={0} text={` ${symbol} `} color={color} messageLayer />
99
+ )}
100
+ <SelectableText y={row} col={HEADER_LABEL_COL} text={info.label} color={color} messageLayer />
101
+ </Box>
102
+ )
103
+ }
104
+ case 'blank':
105
+ return <Text> </Text>
106
+ }
107
+ },
108
+ (prev, next) => {
109
+ if (prev.row !== next.row) return false
110
+ if (prev.themeTick !== next.themeTick) return false
111
+ const a = prev.info
112
+ const b = next.info
113
+ return (
114
+ a.kind === b.kind &&
115
+ a.text === b.text &&
116
+ a.label === b.label &&
117
+ a.role === b.role &&
118
+ a.muted === b.muted &&
119
+ a.background === b.background &&
120
+ a.backgroundWidth === b.backgroundWidth &&
121
+ a.colStart === b.colStart &&
122
+ a.selectable === b.selectable &&
123
+ a.clickable === b.clickable &&
124
+ a.collapsed === b.collapsed &&
125
+ a.thinking === b.thinking &&
126
+ a.segKey === b.segKey &&
127
+ a.spinner === b.spinner &&
128
+ a.wave === b.wave &&
129
+ a.accent === b.accent &&
130
+ a.lineBg === b.lineBg
131
+ )
132
+ },
133
+ )
134
+
135
+ function backgroundFor(role: RowInfo['role']): string {
136
+ return role === 'user' ? COLORS.userBubbleBackground : COLORS.aiBubbleBackground
137
+ }
@@ -0,0 +1,16 @@
1
+ import { keyedRegistry } from '../../kernel/registry.ts'
2
+ import type { MessageViewContribution } from '../../contract/index.ts'
3
+
4
+ const views = keyedRegistry<MessageViewContribution>()
5
+
6
+ export function registerMessageView(contribution: MessageViewContribution): () => void {
7
+ return views.register(contribution.view, contribution, { order: contribution.order })
8
+ }
9
+
10
+ export function messageViewOf(view: string): MessageViewContribution | undefined {
11
+ return views.get(view)
12
+ }
13
+
14
+ export function subscribeMessageViews(listener: () => void): () => void {
15
+ return views.subscribe(listener)
16
+ }
@@ -0,0 +1,39 @@
1
+ import { keyedRegistry } from '../../kernel/registry.ts'
2
+ import type { Message, MessageKind } from '../../model/message.ts'
3
+ import type { Segment } from '../../core/segments.ts'
4
+
5
+ export interface MessageRendererResult {
6
+ lines: string[]
7
+ rows?: Segment[][] | null
8
+ bgs?: (string | undefined)[] | null
9
+ /** Custom messages only: override the default view-key label. */
10
+ customLabel?: string
11
+ customMuted?: boolean
12
+ }
13
+
14
+ export interface MessageRendererContribution {
15
+ /** Message kind to take over: 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom'. */
16
+ kind: MessageKind
17
+ order?: number
18
+ render(message: Message, width: number): MessageRendererResult | undefined
19
+ }
20
+
21
+ const renderers = keyedRegistry<MessageRendererContribution>()
22
+
23
+ /**
24
+ * Kind-keyed takeover of builtin message body rendering. Builtin rendering
25
+ * stays inline in layout.ts and applies only when no override is registered,
26
+ * so a plugin can re-present todo bubbles, plan text, diff bodies, and even
27
+ * assistant bubbles. Disposal restores the builtin path.
28
+ */
29
+ export function registerMessageRenderer(contribution: MessageRendererContribution): () => void {
30
+ return renderers.register(contribution.kind, contribution, { order: contribution.order })
31
+ }
32
+
33
+ export function messageRendererOf(kind: MessageKind): MessageRendererContribution | undefined {
34
+ return renderers.get(kind)
35
+ }
36
+
37
+ export function subscribeMessageRenderers(listener: () => void): () => void {
38
+ return renderers.subscribe(listener)
39
+ }
@@ -0,0 +1,188 @@
1
+ import { wrapSegments } from '../../core/segments.ts'
2
+ import type { Segment } from '../../core/segments.ts'
3
+ import { COLORS } from '../../theme.ts'
4
+ import { highlightCodeBlock, LANGUAGE_ALIASES } from './md/highlight.ts'
5
+ import type { DiffLine } from '../../model/message.ts'
6
+
7
+ const FILENAME_LANGUAGES: Record<string, string> = {
8
+ makefile: 'makefile',
9
+ dockerfile: 'docker',
10
+ 'cmakelists.txt': 'cmake',
11
+ }
12
+
13
+ export function languageFromPath(path: string): string {
14
+ const name = path.replace(/\\/g, '/').split('/').pop() ?? ''
15
+ const filename = FILENAME_LANGUAGES[name.toLowerCase()]
16
+ if (filename !== undefined) return filename
17
+ const dot = name.lastIndexOf('.')
18
+ if (dot < 0) return ''
19
+ return LANGUAGE_ALIASES[name.slice(dot + 1).toLowerCase()] ?? ''
20
+ }
21
+
22
+ const SNIFF_RULES: readonly (readonly [RegExp, string])[] = [
23
+ [/^#!.*\b(ba|z|k)?sh\b/, 'bash'],
24
+ [/^\s*<\?php/, 'php'],
25
+ [/^\s*<!DOCTYPE\s+html/i, 'markup'],
26
+ [/^\s*[{\[][\s\S]*"[^"]+"\s*:/, 'json'],
27
+ [/\b(?:def\s+\w+\s*\(|elif\s|from\s+\w+\s+import\b|__name__\s*==)/, 'python'],
28
+ [/\b(?:package\s+\w+[;\s]|func\s+\w*\(|import\s+"|\bnil\b)/, 'go'],
29
+ [/\bfn\s+\w+\s*\(|\blet\s+mut\b|\bimpl\s+\w+\s+for\b/, 'rust'],
30
+ [/\b(?:interface\s+\w+\s*[<{]|type\s+\w+\s*=\s*[^=]|:\s*(?:string|number|boolean)\b|export\s+(?:default|const|function|class|interface)\b|import\s+[\w{*}\s,]+\s+from\s+)/, 'typescript'],
31
+ [/\b(?:require\(|module\.exports|console\.log\(|function\s+\w+\s*\()/, 'javascript'],
32
+ [/=>|\bconst\s+\w+\s*=|\blet\s+\w+\s*=/, 'javascript'],
33
+ [/^\s*(?:SELECT\b|INSERT\s+INTO\b|CREATE\s+TABLE\b)/im, 'sql'],
34
+ ]
35
+
36
+ export function sniffLanguage(content: string): string {
37
+ const sample = content.length > 4000 ? content.slice(0, 4000) : content
38
+ for (const [pattern, lang] of SNIFF_RULES) {
39
+ if (pattern.test(sample)) return lang
40
+ }
41
+ return ''
42
+ }
43
+
44
+ export interface ToolDiffBody {
45
+ lines: string[]
46
+ rows: Segment[][]
47
+ bgs: (string | undefined)[]
48
+ }
49
+
50
+ const PLAIN_STYLE = { color: COLORS.mdCodePlain }
51
+
52
+ function splitHighlightedLines(source: string, lang: string, streamId: string): Segment[][] {
53
+ if (source === '') return []
54
+ const highlighted = highlightCodeBlock(source, lang, false, streamId)
55
+ const segments = highlighted ?? [{ text: source, style: PLAIN_STYLE }]
56
+ const out: Segment[][] = [[]]
57
+ for (const segment of segments) {
58
+ const parts = segment.text.split('\n')
59
+ for (let p = 0; p < parts.length; p++) {
60
+ if (p > 0) out.push([])
61
+ const part = parts[p]!
62
+ if (part !== '') out[out.length - 1]!.push({ text: part, style: segment.style })
63
+ }
64
+ }
65
+ return out
66
+ }
67
+
68
+ export interface ToolDiffLike {
69
+ tool?: string
70
+ path: string
71
+ hunks: readonly (readonly DiffLine[])[]
72
+ error?: string
73
+ /**
74
+ * Paint whole-line red/green backgrounds behind removed and added lines.
75
+ * Defaults to true when the diff contains any removal: a pure-addition card
76
+ * (a fresh file write) would otherwise read as one noisy column of green.
77
+ * Contributions set this explicitly to override the heuristic either way.
78
+ */
79
+ backgrounds?: boolean
80
+ }
81
+
82
+ function hasRemovals(hunks: readonly (readonly DiffLine[])[]): boolean {
83
+ return hunks.some(hunk => hunk.some(line => line.kind === 'del'))
84
+ }
85
+
86
+ export function renderToolDiffBody(message: ToolDiffLike, width: number): ToolDiffBody {
87
+ const budget = Math.max(4, width)
88
+ const bodyWidth = Math.max(2, budget - 2)
89
+ const lines: string[] = []
90
+ const rows: Segment[][] = []
91
+ const bgs: (string | undefined)[] = []
92
+ const sourceText = message.hunks.map(hunk => hunk.map(line => line.text).join('\n')).join('\n')
93
+ const fromPath = message.path === '' ? '' : languageFromPath(message.path)
94
+ const lang = fromPath !== '' ? fromPath : sniffLanguage(sourceText)
95
+ const withBackgrounds = message.backgrounds ?? hasRemovals(message.hunks)
96
+ for (const [hunkIndex, hunk] of message.hunks.entries()) {
97
+ const perLine = splitHighlightedLines(
98
+ hunk.map(line => line.text).join('\n'),
99
+ lang,
100
+ `${message.tool ?? ''}\u0000${message.path}\u0000${hunkIndex}`,
101
+ )
102
+ for (let index = 0; index < hunk.length; index++) {
103
+ const line = hunk[index]!
104
+ let bg: string | undefined
105
+ let prefix: Segment[]
106
+ if (line.kind === 'add') {
107
+ bg = withBackgrounds ? COLORS.diffAddedBackground : undefined
108
+ prefix = [{ text: '+', style: { color: COLORS.diffAdded } }, { text: ' ', style: {} }]
109
+ } else if (line.kind === 'del') {
110
+ bg = withBackgrounds ? COLORS.diffRemovedBackground : undefined
111
+ prefix = [{ text: '-', style: { color: COLORS.diffRemoved } }, { text: ' ', style: {} }]
112
+ } else {
113
+ prefix = [{ text: ' ', style: {} }]
114
+ }
115
+ const wrapped = wrapSegments(perLine[index] ?? [], bodyWidth)
116
+ for (let r = 0; r < wrapped.length; r++) {
117
+ const codeRow = wrapped[r] ?? []
118
+ const row = r === 0 ? [...prefix, ...codeRow] : [{ text: ' ', style: {} }, ...codeRow]
119
+ rows.push(row)
120
+ lines.push(row.map(segment => segment.text).join(''))
121
+ bgs.push(bg)
122
+ }
123
+ }
124
+ }
125
+ if (message.error !== undefined && message.error !== '') {
126
+ rows.push([])
127
+ lines.push('')
128
+ bgs.push(undefined)
129
+ const wrapped = wrapSegments([{ text: message.error, style: { color: COLORS.errorText } }], bodyWidth)
130
+ for (const segs of wrapped) {
131
+ rows.push(segs)
132
+ lines.push(segs.map(segment => segment.text).join(''))
133
+ bgs.push(undefined)
134
+ }
135
+ }
136
+ return { lines, rows, bgs }
137
+ }
138
+
139
+ export interface ToolReadLike {
140
+ path?: string
141
+ lines: readonly { number: number; text: string }[]
142
+ offset?: number
143
+ totalLines?: number
144
+ lang?: string
145
+ }
146
+
147
+ /**
148
+ * The read card body: a line-number gutter beside syntax-highlighted code,
149
+ * closed by a "lines N-M of total" footer when the window stops short of the
150
+ * end of the file. The gutter is part of the row segments, so the code column
151
+ * stays aligned across wraps.
152
+ */
153
+ export function renderToolReadBody(view: ToolReadLike, width: number): ToolDiffBody {
154
+ const budget = Math.max(4, width)
155
+ const lines: string[] = []
156
+ const rows: Segment[][] = []
157
+ const bgs: (string | undefined)[] = []
158
+ const gutterWidth = view.lines.reduce((max, line) => Math.max(max, String(line.number).length), 1)
159
+ const codeWidth = Math.max(2, budget - gutterWidth - 1)
160
+ const source = view.lines.map(line => line.text).join('\n')
161
+ const fromPath = view.path === undefined || view.path === '' ? '' : languageFromPath(view.path)
162
+ const lang = view.lang !== undefined && view.lang !== ''
163
+ ? view.lang
164
+ : fromPath !== '' ? fromPath : sniffLanguage(source)
165
+ const perLine = splitHighlightedLines(source, lang, `read\u0000${view.path ?? ''}`)
166
+ for (const [index, line] of view.lines.entries()) {
167
+ const gutter = String(line.number).padStart(gutterWidth)
168
+ const prefix: Segment[] = [{ text: gutter, style: { color: COLORS.toolBodyText } }, { text: ' ', style: {} }]
169
+ const wrapped = wrapSegments(perLine[index] ?? [], codeWidth)
170
+ for (let r = 0; r < wrapped.length; r++) {
171
+ const row = r === 0 ? [...prefix, ...(wrapped[r] ?? [])] : [{ text: `${' '.repeat(gutterWidth)} `, style: {} }, ...(wrapped[r] ?? [])]
172
+ rows.push(row)
173
+ lines.push(row.map(segment => segment.text).join(''))
174
+ bgs.push(undefined)
175
+ }
176
+ }
177
+ const first = view.lines[0]?.number ?? view.offset ?? 1
178
+ const last = view.lines.length === 0 ? first - 1 : view.lines[view.lines.length - 1]!.number
179
+ if (view.totalLines !== undefined && view.totalLines > last) {
180
+ const text = view.lines.length === 0
181
+ ? `... empty window at line ${first} of ${view.totalLines}`
182
+ : `... lines ${first}-${last} of ${view.totalLines}`
183
+ rows.push([{ text, style: { color: COLORS.toolBodyText } }])
184
+ lines.push(text)
185
+ bgs.push(undefined)
186
+ }
187
+ return { lines, rows, bgs }
188
+ }
@@ -0,0 +1,41 @@
1
+ import { renderMarkdown } from './md/index.ts'
2
+ import { buildRowIndex } from './layout.ts'
3
+ import type { Message } from '../../model/message.ts'
4
+
5
+ const MARKDOWN_FIXTURE = [
6
+ '# Heading',
7
+ '',
8
+ 'Paragraph with **bold**, *italic*, `inline code` and a [link](https://example.com).',
9
+ '',
10
+ '- list item one',
11
+ '- list item two',
12
+ '',
13
+ '```ts',
14
+ 'export function warm(input: string): number {',
15
+ ' return input.length + 1',
16
+ '}',
17
+ '```',
18
+ '',
19
+ '> quoted text',
20
+ ].join('\n')
21
+
22
+ /**
23
+ * Run the markdown and row-layout pipeline once on a small fixture so the
24
+ * first real message does not pay one-time lexer, highlighter, and JIT
25
+ * initialization. Call during idle after the syntax languages finish warming.
26
+ */
27
+ export function warmRenderPipeline(): void {
28
+ renderMarkdown(MARKDOWN_FIXTURE, 96)
29
+ renderMarkdown(MARKDOWN_FIXTURE, 72)
30
+ const messages: Message[] = [
31
+ { kind: 'home-logo', id: 'warm-logo', lines: ['██ ████'], colors: ['#7aa2f7'], info: ['dsh 0.0.0', 'dshtui 0.0.0'] },
32
+ { kind: 'bubble', id: 'warm-user', role: 'user', content: 'warm up the render pipeline' },
33
+ { kind: 'bubble', id: 'warm-ai', role: 'assistant', content: MARKDOWN_FIXTURE },
34
+ { kind: 'collapsible', id: 'warm-think', label: 'Thinking', body: 'thinking fixture body', running: false, collapsed: false, thinking: true },
35
+ { kind: 'tool-card', id: 'warm-diff', tool: 'edit', label: 'edit src/warm.ts', argsBody: '', diff: { path: 'src/warm.ts', hunks: [[{ kind: 'ctx', text: 'const keep = 1' }, { kind: 'add', text: 'const added = 2' }]] }, running: false },
36
+ { kind: 'tool-card', id: 'warm-card', tool: 'bash', label: 'bash[ls]', argsBody: '{\n "command": "ls"\n}', resultBody: 'src\ntests', running: false },
37
+ { kind: 'bubble', id: 'warm-cmd', role: 'assistant', content: 'command output', origin: 'command' },
38
+ ]
39
+ const index = buildRowIndex(messages, 96)
40
+ for (let row = 0; row < index.total; row++) index.rowAt(row)
41
+ }