dsh-code 0.2.0 → 0.4.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.
- package/README.md +13 -2
- package/README.zh.md +11 -2
- package/cordis.patch.yml +6 -0
- package/lib/index.mjs +2793 -394
- package/lib/types/app.d.ts +12 -0
- package/lib/types/mentions.d.ts +70 -0
- package/lib/types/questions.d.ts +48 -0
- package/lib/types/render/animations.d.ts +15 -0
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +30 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +107 -2
- package/lib/types/render/status.d.ts +21 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/store.d.ts +2 -0
- package/lib/types/theme.d.ts +4 -0
- package/package.json +20 -2
- package/src/app.ts +1507 -149
- package/src/index.ts +156 -46
- package/src/mentions.ts +193 -0
- package/src/pictures/1.png +0 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +79 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +235 -0
- package/src/render/projection.ts +322 -18
- package/src/render/status.ts +62 -3
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/render/tool-preview.ts +34 -0
- package/src/store.ts +8 -0
- package/src/theme.ts +4 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown export of one transcript view: the /export command's pure
|
|
3
|
+
* formatter. Deterministic and side-effect free — the runner owns the file
|
|
4
|
+
* write, so tests drive the builder with folded views directly.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-code/render/export
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import type { TranscriptView } from './projection.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render the transcript as a standalone markdown document.
|
|
14
|
+
* @param view - the folded transcript view to export.
|
|
15
|
+
* @param sessionId - the full session identity for the header.
|
|
16
|
+
* @returns the complete markdown text.
|
|
17
|
+
*/
|
|
18
|
+
export function buildExportMarkdown(view: TranscriptView, sessionId: string): string {
|
|
19
|
+
const out: string[] = [
|
|
20
|
+
view.title === ''
|
|
21
|
+
? `# dsh session ${sessionId}`
|
|
22
|
+
: `# ${view.title}`,
|
|
23
|
+
`> session ${sessionId}`,
|
|
24
|
+
'',
|
|
25
|
+
]
|
|
26
|
+
for (const entry of view.entries) {
|
|
27
|
+
switch (entry.kind) {
|
|
28
|
+
case 'user':
|
|
29
|
+
if (entry.notice) {
|
|
30
|
+
out.push(`> ⤷ context: ${entry.text}`, '')
|
|
31
|
+
} else {
|
|
32
|
+
out.push('## user', '', entry.text, '')
|
|
33
|
+
}
|
|
34
|
+
break
|
|
35
|
+
case 'assistant':
|
|
36
|
+
if (entry.reasoning !== '') {
|
|
37
|
+
out.push('<details><summary>thinking</summary>', '', entry.reasoning, '', '</details>', '')
|
|
38
|
+
}
|
|
39
|
+
out.push('## assistant', '', entry.text, '')
|
|
40
|
+
break
|
|
41
|
+
case 'tool':
|
|
42
|
+
out.push(`### tool \`${entry.name}\``, '')
|
|
43
|
+
if (entry.preview !== '') out.push(`- args: ${entry.preview}`)
|
|
44
|
+
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
|
|
45
|
+
out.push('')
|
|
46
|
+
break
|
|
47
|
+
case 'command':
|
|
48
|
+
out.push(`### /${entry.name}${entry.args === '' ? '' : ` ${entry.args}`}`, '')
|
|
49
|
+
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
|
|
50
|
+
out.push('')
|
|
51
|
+
break
|
|
52
|
+
case 'error':
|
|
53
|
+
out.push(`> ⨯ ${entry.text}`, '')
|
|
54
|
+
break
|
|
55
|
+
case 'turn-marker':
|
|
56
|
+
out.push(`> ${entry.text}`, '')
|
|
57
|
+
break
|
|
58
|
+
case 'compaction':
|
|
59
|
+
out.push(entry.ok
|
|
60
|
+
? `> compacted ~${entry.tokens} tokens`
|
|
61
|
+
: `> compaction failed: ${entry.error}`, '')
|
|
62
|
+
break
|
|
63
|
+
case 'retry':
|
|
64
|
+
out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, '')
|
|
65
|
+
break
|
|
66
|
+
case 'files':
|
|
67
|
+
out.push(`> files changed: ${entry.paths.join(', ')}`, '')
|
|
68
|
+
break
|
|
69
|
+
default:
|
|
70
|
+
assertNever(entry, 'transcript entry kind')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (view.streaming !== '') out.push('## assistant (streaming)', '', view.streaming, '')
|
|
74
|
+
const { stats } = view
|
|
75
|
+
out.push('---', '')
|
|
76
|
+
out.push(`- model: ${view.model === '' ? '(none yet)' : view.model}`)
|
|
77
|
+
out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`)
|
|
78
|
+
out.push(`- tokens: ↑${stats.usage.inputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens}`)
|
|
79
|
+
out.push(`- todos: ${view.todos.length}`)
|
|
80
|
+
return out.join('\n')
|
|
81
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Pure viewport, selection, and scrolling rules for exclusive TUI panels. */
|
|
2
|
+
|
|
3
|
+
/** Terminal-space allocation for the inspector's one dynamic screen. */
|
|
4
|
+
export interface InspectorViewport {
|
|
5
|
+
/** Maximum dynamic rows, kept strictly below the terminal height. */
|
|
6
|
+
maxHeight: number
|
|
7
|
+
/** Rows available to the selected entry after border, title, and footer. */
|
|
8
|
+
bodyRows: number
|
|
9
|
+
/** Columns available inside the horizontal border and padding. */
|
|
10
|
+
contentColumns: number
|
|
11
|
+
/** Tiny terminals use a borderless one-line close hint. */
|
|
12
|
+
compact: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The three-row read-only composer frame plus its one-row status footer. */
|
|
16
|
+
const INSPECTOR_CHROME_ROWS = 4
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Keep the inspector plus its persistent status/composer chrome below
|
|
20
|
+
* `stdout.rows`: at equality Ink clears the terminal and rewrites all
|
|
21
|
+
* accumulated `<Static>` output on every frame.
|
|
22
|
+
*/
|
|
23
|
+
export function panelViewport(columns: number, rows: number): InspectorViewport {
|
|
24
|
+
const safeColumns = Math.max(1, Math.floor(columns))
|
|
25
|
+
const safeRows = Math.max(1, Math.floor(rows))
|
|
26
|
+
// Two spare rows cover Ink's first-frame transition from existing Static
|
|
27
|
+
// scrollback into a tall dynamic panel. A one-row margin is insufficient:
|
|
28
|
+
// the transition can still take the full-terminal rewrite path at rows - 1.
|
|
29
|
+
const maxHeight = Math.max(0, Math.min(
|
|
30
|
+
safeRows - 2 - INSPECTOR_CHROME_ROWS,
|
|
31
|
+
Math.floor(safeRows / 2),
|
|
32
|
+
))
|
|
33
|
+
const compact = maxHeight < 5 || safeColumns < 8
|
|
34
|
+
return {
|
|
35
|
+
maxHeight,
|
|
36
|
+
bodyRows: compact ? 0 : maxHeight - 4,
|
|
37
|
+
contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
|
|
38
|
+
compact,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Backward-compatible name for the Ctrl+O-specific caller and tests. */
|
|
43
|
+
export function inspectorViewport(columns: number, rows: number): InspectorViewport {
|
|
44
|
+
return panelViewport(columns, rows)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clamp a first-visible row to the range representable by one viewport. */
|
|
48
|
+
export function clampScroll(offset: number, totalRows: number, visibleRows: number): number {
|
|
49
|
+
const total = Math.max(0, Math.floor(totalRows))
|
|
50
|
+
const size = Math.max(0, Math.floor(visibleRows))
|
|
51
|
+
const last = Math.max(0, total - size)
|
|
52
|
+
return Math.max(0, Math.min(Math.floor(offset), last))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Move a viewport by a signed row delta without escaping its content. */
|
|
56
|
+
export function moveScroll(offset: number, delta: number, totalRows: number, visibleRows: number): number {
|
|
57
|
+
return clampScroll(offset + delta, totalRows, visibleRows)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Keep one focused row visible while preserving the current window when possible. */
|
|
61
|
+
export function revealRow(offset: number, row: number, totalRows: number, visibleRows: number): number {
|
|
62
|
+
const size = Math.max(1, Math.floor(visibleRows))
|
|
63
|
+
const target = Math.max(0, Math.min(Math.floor(row), Math.max(0, totalRows - 1)))
|
|
64
|
+
if (target < offset) return clampScroll(target, totalRows, size)
|
|
65
|
+
if (target >= offset + size) return clampScroll(target - size + 1, totalRows, size)
|
|
66
|
+
return clampScroll(offset, totalRows, size)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Center a selected list row where possible, clamped at both ends. */
|
|
70
|
+
export function selectionWindow(cursor: number, totalRows: number, visibleRows: number): number {
|
|
71
|
+
return clampScroll(cursor - Math.floor(Math.max(1, visibleRows) / 2), totalRows, visibleRows)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Follow appended history only while the inspector cursor was at the tail. */
|
|
75
|
+
export function followInspectorCursor(cursor: number, previousLength: number, nextLength: number): number {
|
|
76
|
+
const nextLast = Math.max(0, nextLength - 1)
|
|
77
|
+
if (cursor >= Math.max(0, previousLength - 1)) return nextLast
|
|
78
|
+
return Math.min(cursor, nextLast)
|
|
79
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** Width-safe styled physical rows for bounded terminal panels. */
|
|
2
|
+
|
|
3
|
+
import type { TranscriptEntry } from './projection.ts'
|
|
4
|
+
import type { ToolDetail } from './tool-detail.ts'
|
|
5
|
+
import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
|
|
6
|
+
import { formatTokens } from './status.ts'
|
|
7
|
+
import { displayText } from './text.ts'
|
|
8
|
+
|
|
9
|
+
/** Presentation classes mapped to Ink colors by the app boundary. */
|
|
10
|
+
export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic'
|
|
11
|
+
|
|
12
|
+
/** One styled run within a physical terminal row. */
|
|
13
|
+
export interface StyledSegment {
|
|
14
|
+
text: string
|
|
15
|
+
style: LineStyle
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One row guaranteed not to exceed the requested terminal width. */
|
|
19
|
+
export interface StyledLine {
|
|
20
|
+
segments: readonly StyledSegment[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Construct one segment without leaking mutable objects into cached rows. */
|
|
24
|
+
export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
|
|
25
|
+
return { text, style }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Append a character while merging adjacent runs with the same style. */
|
|
29
|
+
function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
|
|
30
|
+
const previous = target[target.length - 1]
|
|
31
|
+
if (previous?.style === style) {
|
|
32
|
+
target[target.length - 1] = { text: previous.text + text, style }
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
target.push({ text, style })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize and hard-wrap styled content into exact physical rows.
|
|
40
|
+
* Tabs become two visible spaces because terminal tab stops are contextual
|
|
41
|
+
* and therefore cannot participate in a deterministic row budget.
|
|
42
|
+
*/
|
|
43
|
+
export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
|
|
44
|
+
const width = Math.max(1, Math.floor(columns))
|
|
45
|
+
const lines: StyledLine[] = []
|
|
46
|
+
let current: StyledSegment[] = []
|
|
47
|
+
let used = 0
|
|
48
|
+
const flush = (): void => {
|
|
49
|
+
lines.push({ segments: current })
|
|
50
|
+
current = []
|
|
51
|
+
used = 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
56
|
+
for (const char of safe) {
|
|
57
|
+
if (char === '\n') {
|
|
58
|
+
flush()
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const cells = visibleColumns(char)
|
|
62
|
+
if (used > 0 && used + cells > width) flush()
|
|
63
|
+
appendSegment(current, char, segment.style)
|
|
64
|
+
used += cells
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (current.length > 0 || lines.length === 0) flush()
|
|
68
|
+
return lines
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Plain/dim text convenience over {@link styledLines}. */
|
|
72
|
+
export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
|
|
73
|
+
return styledLines([lineSegment(text, style)], columns)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Markdown rows re-hardened so a single long word cannot escape the budget. */
|
|
77
|
+
export function markdownLines(text: string, columns: number): readonly StyledLine[] {
|
|
78
|
+
const width = Math.max(1, Math.floor(columns))
|
|
79
|
+
const parsed = renderMarkdown(displayText(text), Math.max(10, width))
|
|
80
|
+
return parsed.flatMap(line => styledLines(
|
|
81
|
+
line.segments.map(segment => lineSegment(segment.text, segment.style)),
|
|
82
|
+
width,
|
|
83
|
+
))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Expanded structured tool detail as scrollable, width-safe rows. */
|
|
87
|
+
function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
|
|
88
|
+
switch (detail.kind) {
|
|
89
|
+
case 'diff':
|
|
90
|
+
return detail.diffs.flatMap(diff => [
|
|
91
|
+
...styledLines([
|
|
92
|
+
lineSegment(' ── ', 'dim'),
|
|
93
|
+
lineSegment(diff.path, 'dim'),
|
|
94
|
+
lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
|
|
95
|
+
], columns),
|
|
96
|
+
...diff.lines.flatMap(line => styledLines([
|
|
97
|
+
lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
|
|
98
|
+
], columns)),
|
|
99
|
+
])
|
|
100
|
+
case 'read':
|
|
101
|
+
return [
|
|
102
|
+
...textLines(
|
|
103
|
+
` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`,
|
|
104
|
+
columns,
|
|
105
|
+
'dim',
|
|
106
|
+
),
|
|
107
|
+
...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
|
|
108
|
+
]
|
|
109
|
+
case 'web-search':
|
|
110
|
+
return [
|
|
111
|
+
...detail.sources.flatMap(source => [
|
|
112
|
+
...styledLines([
|
|
113
|
+
lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
|
|
114
|
+
lineSegment(` - ${source.url}`, 'dim'),
|
|
115
|
+
], columns),
|
|
116
|
+
...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
|
|
117
|
+
]),
|
|
118
|
+
...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
|
|
119
|
+
]
|
|
120
|
+
case 'web-fetch':
|
|
121
|
+
return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
|
|
122
|
+
case 'raw':
|
|
123
|
+
return [
|
|
124
|
+
...textLines(` ${detail.text}`, columns, 'dim'),
|
|
125
|
+
...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
|
|
126
|
+
]
|
|
127
|
+
default: {
|
|
128
|
+
const exhaustive: never = detail
|
|
129
|
+
return exhaustive
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Convert one durable transcript entry to its complete scrollable row model.
|
|
136
|
+
* The source entry stays intact; only the caller's visible slice is rendered.
|
|
137
|
+
*/
|
|
138
|
+
export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
|
|
139
|
+
const width = Math.max(1, Math.floor(columns))
|
|
140
|
+
switch (entry.kind) {
|
|
141
|
+
case 'user':
|
|
142
|
+
return styledLines([
|
|
143
|
+
lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
|
|
144
|
+
lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
|
|
145
|
+
], width)
|
|
146
|
+
case 'assistant':
|
|
147
|
+
return [
|
|
148
|
+
...(entry.reasoning === ''
|
|
149
|
+
? []
|
|
150
|
+
: styledLines([
|
|
151
|
+
lineSegment(' ✻ ', 'dimItalic'),
|
|
152
|
+
lineSegment(entry.reasoning, 'dimItalic'),
|
|
153
|
+
], width)),
|
|
154
|
+
...markdownLines(entry.text, width),
|
|
155
|
+
]
|
|
156
|
+
case 'tool': {
|
|
157
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
158
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
159
|
+
return [
|
|
160
|
+
...styledLines([
|
|
161
|
+
lineSegment(`${mark} `, markStyle),
|
|
162
|
+
lineSegment(entry.name, 'brand'),
|
|
163
|
+
lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
|
|
164
|
+
], width),
|
|
165
|
+
...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
|
|
166
|
+
...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
case 'command': {
|
|
170
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
171
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
172
|
+
return [
|
|
173
|
+
...styledLines([
|
|
174
|
+
lineSegment(`${mark} `, markStyle),
|
|
175
|
+
lineSegment(`/${entry.name}`, 'brand'),
|
|
176
|
+
lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
|
|
177
|
+
], width),
|
|
178
|
+
...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
case 'turn-marker':
|
|
182
|
+
return textLines(` ⏹ ${entry.text}`, width, 'dim')
|
|
183
|
+
case 'compaction':
|
|
184
|
+
return textLines(entry.ok
|
|
185
|
+
? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
|
|
186
|
+
: ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
|
|
187
|
+
case 'retry':
|
|
188
|
+
return textLines(
|
|
189
|
+
` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
190
|
+
width,
|
|
191
|
+
entry.state === 'running' ? 'warn' : 'dim',
|
|
192
|
+
)
|
|
193
|
+
case 'files':
|
|
194
|
+
return entry.paths.length === 0
|
|
195
|
+
? textLines(' ⎄ no changed files', width, 'dim')
|
|
196
|
+
: [
|
|
197
|
+
...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
|
|
198
|
+
...entry.paths.flatMap(path => textLines(` ${path}`, width, 'dim')),
|
|
199
|
+
]
|
|
200
|
+
case 'error':
|
|
201
|
+
return textLines(entry.text, width, 'error')
|
|
202
|
+
default: {
|
|
203
|
+
const exhaustive: never = entry
|
|
204
|
+
return exhaustive
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal markdown renderer for assistant replies: a pure GFM-subset
|
|
3
|
+
* block/inline parser producing styled line segments the Ink renderer maps
|
|
4
|
+
* to colored text. No ANSI here — the app owns color mapping, tests own the
|
|
5
|
+
* structure. The subset mirrors what agent replies actually emit: headings,
|
|
6
|
+
* emphasis, inline/fenced code, flat lists, blockquotes, links, rules, and
|
|
7
|
+
* wrapped paragraphs. Unknown syntax degrades to plain text (never throws).
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/render/markdown
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Style classes the renderer emits; the app maps them to colors/props. */
|
|
13
|
+
export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'dim' | 'strike'
|
|
14
|
+
|
|
15
|
+
/** One styled run of text. */
|
|
16
|
+
export interface MdSegment {
|
|
17
|
+
/** Visible text (no ANSI). */
|
|
18
|
+
text: string
|
|
19
|
+
/** Presentation class for the app's color map. */
|
|
20
|
+
style: MdStyle
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One rendered line: a sequence of styled runs. */
|
|
24
|
+
export interface MdLine {
|
|
25
|
+
segments: readonly MdSegment[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Plain segment helper. */
|
|
29
|
+
function seg(text: string, style: MdStyle = 'plain'): MdSegment {
|
|
30
|
+
return { text, style }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Visible width of a run in columns (CJK counts double). */
|
|
34
|
+
export function visibleColumns(text: string): number {
|
|
35
|
+
let columns = 0
|
|
36
|
+
for (const char of text) {
|
|
37
|
+
const code = char.codePointAt(0) ?? 0
|
|
38
|
+
columns += code > 0x2e7f ? 2 : 1
|
|
39
|
+
}
|
|
40
|
+
return columns
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Walk a segment list, breaking it into lines that fit `width` columns. */
|
|
44
|
+
function wrapSegments(segments: readonly MdSegment[], width: number): readonly MdSegment[][] {
|
|
45
|
+
const lines: MdSegment[][] = []
|
|
46
|
+
let current: MdSegment[] = []
|
|
47
|
+
let used = 0
|
|
48
|
+
for (const segment of segments) {
|
|
49
|
+
// Break the segment at spaces into words so long runs wrap mid-text.
|
|
50
|
+
const words = segment.text.split(/( )/u)
|
|
51
|
+
for (const word of words) {
|
|
52
|
+
if (word === '') continue
|
|
53
|
+
const columns = visibleColumns(word)
|
|
54
|
+
if (used + columns > width && used > 0) {
|
|
55
|
+
lines.push(current)
|
|
56
|
+
current = []
|
|
57
|
+
used = 0
|
|
58
|
+
}
|
|
59
|
+
// A single word wider than the line still goes on its own line.
|
|
60
|
+
current.push({ text: word, style: segment.style })
|
|
61
|
+
used += columns
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (current.length > 0) lines.push(current)
|
|
65
|
+
// Drop the trailing space a wrapped line picked up before the break.
|
|
66
|
+
return lines.map(line => {
|
|
67
|
+
const last = line[line.length - 1]
|
|
68
|
+
if (last !== undefined && last.text === ' ' && line.length > 1) return line.slice(0, -1)
|
|
69
|
+
return line
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Join adjacent same-style runs so the app renders fewer elements. */
|
|
74
|
+
function merge(segments: readonly MdSegment[]): readonly MdSegment[] {
|
|
75
|
+
const merged: MdSegment[] = []
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
const last = merged[merged.length - 1]
|
|
78
|
+
if (last !== undefined && last.style === segment.style) {
|
|
79
|
+
merged[merged.length - 1] = { text: last.text + segment.text, style: last.style }
|
|
80
|
+
} else {
|
|
81
|
+
merged.push({ ...segment })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return merged
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One parsed inline run before wrapping. */
|
|
88
|
+
interface InlineRun {
|
|
89
|
+
text: string
|
|
90
|
+
style: MdStyle
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse inline markdown in one line of text. Link destinations render as a
|
|
95
|
+
* dim `(url)` suffix — the visible text keeps the accent.
|
|
96
|
+
*/
|
|
97
|
+
function parseInline(text: string): readonly InlineRun[] {
|
|
98
|
+
const runs: InlineRun[] = []
|
|
99
|
+
let rest = text
|
|
100
|
+
while (rest !== '') {
|
|
101
|
+
const code = /^`([^`]+)`/u.exec(rest)
|
|
102
|
+
if (code !== null) {
|
|
103
|
+
runs.push({ text: code[1] ?? '', style: 'code' })
|
|
104
|
+
rest = rest.slice(code[0].length)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const boldItalic = /^\*\*\*([^*]+)\*\*\*/u.exec(rest)
|
|
108
|
+
if (boldItalic !== null) {
|
|
109
|
+
runs.push({ text: boldItalic[1] ?? '', style: 'boldItalic' })
|
|
110
|
+
rest = rest.slice(boldItalic[0].length)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const bold = /^\*\*([^*]+)\*\*/u.exec(rest)
|
|
114
|
+
if (bold !== null) {
|
|
115
|
+
runs.push({ text: bold[1] ?? '', style: 'bold' })
|
|
116
|
+
rest = rest.slice(bold[0].length)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const italic = /^\*([^*]+)\*/u.exec(rest) ?? /^_([^_]+)_/u.exec(rest)
|
|
120
|
+
if (italic !== null) {
|
|
121
|
+
runs.push({ text: italic[1] ?? '', style: 'italic' })
|
|
122
|
+
rest = rest.slice(italic[0].length)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const strike = /^~~([^~]+)~~/u.exec(rest)
|
|
126
|
+
if (strike !== null) {
|
|
127
|
+
runs.push({ text: strike[1] ?? '', style: 'strike' })
|
|
128
|
+
rest = rest.slice(strike[0].length)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const link = /^\[([^\]]+)\]\(([^)\s]+)\)/u.exec(rest)
|
|
132
|
+
if (link !== null) {
|
|
133
|
+
const label = link[1] ?? ''
|
|
134
|
+
const url = link[2] ?? ''
|
|
135
|
+
runs.push({ text: label, style: 'accent' })
|
|
136
|
+
runs.push({ text: ` (${url})`, style: 'dim' })
|
|
137
|
+
rest = rest.slice(link[0].length)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
// Plain run up to the next special opener.
|
|
141
|
+
const next = rest.search(/[*_`~[]/u)
|
|
142
|
+
if (next === -1) {
|
|
143
|
+
runs.push({ text: rest, style: 'plain' })
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
if (next > 0) {
|
|
147
|
+
runs.push({ text: rest.slice(0, next), style: 'plain' })
|
|
148
|
+
rest = rest.slice(next)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
// A special opener at position 0 that no pattern consumed: emit it
|
|
152
|
+
// literally and advance, so unbalanced syntax never loops.
|
|
153
|
+
runs.push({ text: rest.slice(0, 1), style: 'plain' })
|
|
154
|
+
rest = rest.slice(1)
|
|
155
|
+
}
|
|
156
|
+
return runs
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const HEADING = /^(#{1,6})\s+(.*)$/u
|
|
160
|
+
const FENCE = /^```([^\s`]*)\s*$/u
|
|
161
|
+
const RULE = /^(?:---|\*\*\*|___)\s*$/u
|
|
162
|
+
const QUOTE = /^>\s?(.*)$/u
|
|
163
|
+
const UNORDERED = /^\s*[-*+]\s+(.*)$/u
|
|
164
|
+
const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
|
|
165
|
+
|
|
166
|
+
/** Render markdown text into styled lines of at most `width` columns. */
|
|
167
|
+
export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
168
|
+
const lines: MdLine[] = []
|
|
169
|
+
const push = (segments: readonly MdSegment[]): void => {
|
|
170
|
+
for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
|
|
171
|
+
lines.push({ segments: merge(wrapped) })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const raw = text.replaceAll('\r', '')
|
|
175
|
+
const source = raw.split('\n')
|
|
176
|
+
let index = 0
|
|
177
|
+
while (index < source.length) {
|
|
178
|
+
const line = source[index] ?? ''
|
|
179
|
+
index += 1
|
|
180
|
+
|
|
181
|
+
// Fenced code block: verbatim lines in code style, language label first.
|
|
182
|
+
const fence = FENCE.exec(line)
|
|
183
|
+
if (fence !== null) {
|
|
184
|
+
const language = fence[1] ?? ''
|
|
185
|
+
if (language !== '') push([seg(` ${language}`, 'dim')])
|
|
186
|
+
while (index < source.length && !FENCE.test(source[index] ?? '')) {
|
|
187
|
+
push([seg(` ${source[index] ?? ''}`, 'code')])
|
|
188
|
+
index += 1
|
|
189
|
+
}
|
|
190
|
+
index += 1 // closing fence
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (line.trim() === '') continue
|
|
195
|
+
if (RULE.test(line.trim())) {
|
|
196
|
+
push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const heading = HEADING.exec(line)
|
|
200
|
+
if (heading !== null) {
|
|
201
|
+
push([seg(heading[2] ?? '', 'accent')])
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
const quote = QUOTE.exec(line)
|
|
205
|
+
if (quote !== null) {
|
|
206
|
+
push([seg(' │ ', 'accent'), ...parseInline(quote[1] ?? '').map(run => seg(run.text, run.style === 'plain' ? 'dim' : run.style))])
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
const ordered = ORDERED.exec(line)
|
|
210
|
+
if (ordered !== null) {
|
|
211
|
+
push([seg(` ${ordered[1] ?? ''}. `, 'accent'), ...parseInline(ordered[2] ?? '').map(run => seg(run.text, run.style))])
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const unordered = UNORDERED.exec(line)
|
|
215
|
+
if (unordered !== null) {
|
|
216
|
+
push([seg(' • ', 'accent'), ...parseInline(unordered[1] ?? '').map(run => seg(run.text, run.style))])
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Paragraph: gather until a blank line, then wrap as one flow. Line
|
|
221
|
+
// breaks inside a paragraph join as a single space (GFM soft breaks).
|
|
222
|
+
const paragraph = [line]
|
|
223
|
+
while (index < source.length && (source[index] ?? '').trim() !== '') {
|
|
224
|
+
paragraph.push(source[index] ?? '')
|
|
225
|
+
index += 1
|
|
226
|
+
}
|
|
227
|
+
const runs: InlineRun[] = []
|
|
228
|
+
for (let at = 0; at < paragraph.length; at += 1) {
|
|
229
|
+
if (at > 0) runs.push({ text: ' ', style: 'plain' })
|
|
230
|
+
runs.push(...parseInline(paragraph[at] ?? ''))
|
|
231
|
+
}
|
|
232
|
+
push(runs.map(run => seg(run.text, run.style)))
|
|
233
|
+
}
|
|
234
|
+
return lines
|
|
235
|
+
}
|