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/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
+ }