dsh-code 0.3.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 +7 -1
- package/README.zh.md +5 -1
- package/lib/index.mjs +3116 -1703
- package/lib/types/app.d.ts +4 -21
- 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/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +17 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/store.d.ts +2 -0
- package/package.json +11 -1
- package/src/app.ts +1087 -233
- package/src/index.ts +46 -0
- package/src/pictures/1.png +0 -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/projection.ts +279 -16
- package/src/render/status.ts +48 -1
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/store.ts +8 -0
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { randomUUID } from 'node:crypto'
|
|
14
14
|
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { writeFile as writeFileAsync } from 'node:fs/promises'
|
|
15
16
|
import { basename, join } from 'node:path'
|
|
16
17
|
import { createElement } from 'react'
|
|
17
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -22,6 +23,8 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
|
22
23
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
24
|
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
24
25
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
26
|
+
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-session-title'
|
|
25
28
|
// Empty type imports carry the loader Context merge for the settlement await
|
|
26
29
|
// and the cmdline Context merge for the appExit host value.
|
|
27
30
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
@@ -36,6 +39,7 @@ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
|
36
39
|
import { createTranscriptStore } from './store.ts'
|
|
37
40
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
38
41
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
42
|
+
import { buildExportMarkdown } from './render/export.ts'
|
|
39
43
|
import type { TuiStartup } from './startup.ts'
|
|
40
44
|
|
|
41
45
|
/** Stable Cordis plugin name. */
|
|
@@ -405,6 +409,46 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
405
409
|
return `${row.provider}/${row.model}`
|
|
406
410
|
}
|
|
407
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Export the folded transcript to a markdown file (/export). The default
|
|
414
|
+
* target sits beside the session's cwd so the file lands in the user's
|
|
415
|
+
* workspace; an absolute or cwd-relative argument overrides it.
|
|
416
|
+
*/
|
|
417
|
+
const exportTranscript = async (argument: string): Promise<void> => {
|
|
418
|
+
const wanted = argument.trim()
|
|
419
|
+
const defaultName = `dsh-session-${session.id.slice(-8)}.md`
|
|
420
|
+
const target = wanted === ''
|
|
421
|
+
? join(cwd, defaultName)
|
|
422
|
+
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
423
|
+
? wanted
|
|
424
|
+
: join(cwd, wanted)
|
|
425
|
+
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
426
|
+
try {
|
|
427
|
+
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
428
|
+
bridge.notify(`exported to ${target}`)
|
|
429
|
+
} catch (error: unknown) {
|
|
430
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Rename the session (/title): a user title pins the session and stops
|
|
436
|
+
* automatic generation (the service's own contract). The appended
|
|
437
|
+
* `session/title` event flows back through the store into the status line.
|
|
438
|
+
*/
|
|
439
|
+
const renameTitle = (argument: string): string => {
|
|
440
|
+
const title = argument.trim()
|
|
441
|
+
if (title === '') return 'usage: /title <text>'
|
|
442
|
+
const service = ctx.get('sessionTitle')
|
|
443
|
+
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
444
|
+
try {
|
|
445
|
+
service.rename(session, title)
|
|
446
|
+
return `title → ${title}`
|
|
447
|
+
} catch (error: unknown) {
|
|
448
|
+
return `rename failed: ${error instanceof Error ? error.message : String(error)}`
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
408
452
|
const initialModel = store.getView().model !== ''
|
|
409
453
|
? store.getView().model
|
|
410
454
|
: `${defaults.provider}/${defaults.model}`
|
|
@@ -428,6 +472,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
428
472
|
loadMentions: mentions.candidates,
|
|
429
473
|
cyclePermission,
|
|
430
474
|
selectModel,
|
|
475
|
+
exportTranscript,
|
|
476
|
+
renameTitle,
|
|
431
477
|
onBridgeReady: (instance: AppBridge) => {
|
|
432
478
|
bridge.notify = instance.notify
|
|
433
479
|
},
|
|
Binary file
|
|
@@ -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
|
+
}
|