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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Expansion payloads for tool cards (the Ctrl+O verbose transcript): the
3
+ * TUI-side consumption of the harness presentation contract. Mutation and
4
+ * read tools persist a structured `tool/result.meta` (`diffs`, read
5
+ * windows, web sources) exactly so a capable UI can replay richer cards than
6
+ * the model-facing text; this module narrows that opaque JSON defensively —
7
+ * mirroring the upstream validators — and pre-formats bounded, render-ready
8
+ * rows. Malformed or absent metadata always degrades to the bounded raw
9
+ * result text, never throws during replay.
10
+ *
11
+ * @module @deepseek-ai/dsh-code/render/tool-detail
12
+ */
13
+
14
+ /** Budgets keeping one expanded card bounded on a terminal. */
15
+ const MAX_DIFF_LINES = 200
16
+ const MAX_READ_LINES = 120
17
+ const MAX_SOURCES = 10
18
+ const MAX_RAW_CHARS = 6000
19
+ const MAX_LINE_COLUMNS = 240
20
+
21
+ /** One rendered diff row: removed, added, or shared context. */
22
+ export interface DiffLine {
23
+ /** '-' removed, '+' added, ' ' context. */
24
+ mark: '-' | '+' | ' '
25
+ /** The line text, truncated to the column budget. */
26
+ text: string
27
+ }
28
+
29
+ /** One file's bounded inline diff. */
30
+ export interface ToolDiff {
31
+ /** File path the change belongs to. */
32
+ path: string
33
+ /** Rendered rows in order; '-' block before the '+' block. */
34
+ lines: readonly DiffLine[]
35
+ /** True when the line budget cut the hunk. */
36
+ truncated: boolean
37
+ }
38
+
39
+ /** One numbered line of a read window. */
40
+ export interface ToolReadLine {
41
+ /** 1-based file line number. */
42
+ number: number
43
+ /** The line text, truncated to the column budget. */
44
+ text: string
45
+ }
46
+
47
+ /** One web-search source row. */
48
+ export interface ToolWebSource {
49
+ /** Source URL. */
50
+ url: string
51
+ /** Source title, when the provider returned one. */
52
+ title: string | undefined
53
+ /** Short excerpt, truncated to the column budget. */
54
+ snippet: string
55
+ }
56
+
57
+ /** The expansion payload a verbose tool card renders; a discriminated union. */
58
+ export type ToolDetail =
59
+ | { kind: 'diff'; diffs: readonly ToolDiff[] }
60
+ | { kind: 'read'; path: string; offset: number; lines: readonly ToolReadLine[]; totalLines: number; truncated: boolean }
61
+ | { kind: 'web-search'; sources: readonly ToolWebSource[]; truncated: boolean }
62
+ | { kind: 'web-fetch'; url: string; statusCode: number }
63
+ | { kind: 'raw'; text: string; truncated: boolean }
64
+
65
+ /** Truncate one line to the visible-column budget with an ellipsis marker. */
66
+ function clipLine(text: string): string {
67
+ return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, MAX_LINE_COLUMNS - 1)}…` : text
68
+ }
69
+
70
+ /** Split text into lines, dropping the trailing empty element of a final newline. */
71
+ function toLines(text: string): string[] {
72
+ const split = text.split('\n')
73
+ return split.length > 0 && split[split.length - 1] === '' ? split.slice(0, -1) : split
74
+ }
75
+
76
+ /**
77
+ * Render one change as removed-then-added rows, hunked by common prefix and
78
+ * suffix. A null before-image (file create) renders as pure additions. The
79
+ * budget caps emitted rows and reports the cut, so a whole-file overwrite
80
+ * never floods the transcript.
81
+ * @param oldText - prior content, or null for a create.
82
+ * @param newText - content after the change.
83
+ * @param budget - maximum rows to emit.
84
+ * @returns the bounded rows and whether they were cut.
85
+ */
86
+ export function diffRows(oldText: string | null, newText: string, budget: number): { lines: readonly DiffLine[]; truncated: boolean } {
87
+ const oldLines = oldText === null ? [] : toLines(oldText)
88
+ const newLines = toLines(newText)
89
+ let prefix = 0
90
+ while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1
91
+ let suffix = 0
92
+ while (
93
+ suffix < oldLines.length - prefix && suffix < newLines.length - prefix
94
+ && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
95
+ ) suffix += 1
96
+ const removed = oldLines.slice(prefix, oldLines.length - suffix)
97
+ const added = newLines.slice(prefix, newLines.length - suffix)
98
+ const rows: DiffLine[] = [
99
+ ...removed.map((text): DiffLine => ({ mark: '-', text: clipLine(text) })),
100
+ ...added.map((text): DiffLine => ({ mark: '+', text: clipLine(text) })),
101
+ ]
102
+ if (rows.length <= budget) return { lines: rows, truncated: false }
103
+ return { lines: rows.slice(0, budget), truncated: true }
104
+ }
105
+
106
+ /** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
107
+ function isFileDiff(value: unknown): value is { path: string; oldText: string | null; newText: string } {
108
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
109
+ const { path, oldText, newText } = value as Record<string, unknown>
110
+ return typeof path === 'string' && (oldText === null || typeof oldText === 'string') && typeof newText === 'string'
111
+ }
112
+
113
+ /** Whether `value` is a valid read-window line (defensive narrowing). */
114
+ function isReadLine(value: unknown): value is { number: number; text: string } {
115
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
116
+ const { number, text } = value as Record<string, unknown>
117
+ return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
118
+ }
119
+
120
+ /** Whether `value` is a valid web source (defensive narrowing). */
121
+ function isWebSource(value: unknown): value is { url: string; title?: unknown; snippet?: unknown } {
122
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
123
+ const { url, title, snippet } = value as Record<string, unknown>
124
+ return typeof url === 'string'
125
+ && (title === undefined || typeof title === 'string')
126
+ && (snippet === undefined || typeof snippet === 'string')
127
+ }
128
+
129
+ /**
130
+ * Narrow the opaque `tool/result.meta` into one bounded expansion payload,
131
+ * mirroring the upstream presenters' degradation ladder: diffs (write/edit),
132
+ * read windows (read), sources (web_search), fetch summaries (web_fetch), and
133
+ * the bounded raw result text as the universal fallback.
134
+ * @param meta - the persisted presentation metadata, when the tool attached one.
135
+ * @param rawText - the joined text blocks of the result message.
136
+ * @returns the expansion payload, or undefined when nothing renderable exists.
137
+ */
138
+ export function toolResultDetail(meta: unknown, rawText: string): ToolDetail | undefined {
139
+ if (typeof meta === 'object' && meta !== null && !Array.isArray(meta)) {
140
+ const record = meta as Record<string, unknown>
141
+
142
+ const diffs = record['diffs']
143
+ if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
144
+ const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length))
145
+ return {
146
+ kind: 'diff',
147
+ diffs: diffs.map(diff => ({
148
+ path: diff.path,
149
+ ...diffRows(diff.oldText, diff.newText, budget),
150
+ })),
151
+ }
152
+ }
153
+
154
+ const { path, offset, lines, totalLines } = record
155
+ if (typeof path === 'string' && Number.isInteger(offset) && (offset as number) >= 1
156
+ && Number.isInteger(totalLines) && (totalLines as number) >= 0
157
+ && Array.isArray(lines) && lines.every(isReadLine)) {
158
+ const window = lines as { number: number; text: string }[]
159
+ const truncated = window.length > MAX_READ_LINES
160
+ return {
161
+ kind: 'read',
162
+ path,
163
+ offset: offset as number,
164
+ lines: (truncated ? window.slice(0, MAX_READ_LINES) : window)
165
+ .map(line => ({ number: line.number, text: clipLine(line.text) })),
166
+ totalLines: totalLines as number,
167
+ truncated,
168
+ }
169
+ }
170
+
171
+ const sources = record['sources']
172
+ if (Array.isArray(sources) && sources.every(isWebSource)) {
173
+ const truncated = sources.length > MAX_SOURCES
174
+ return {
175
+ kind: 'web-search',
176
+ sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map(source => ({
177
+ url: source.url,
178
+ title: typeof source.title === 'string' ? source.title : undefined,
179
+ snippet: typeof source.snippet === 'string' ? clipLine(source.snippet) : '',
180
+ })),
181
+ truncated,
182
+ }
183
+ }
184
+
185
+ const { url, statusCode } = record
186
+ if (typeof url === 'string' && typeof statusCode === 'number') {
187
+ return { kind: 'web-fetch', url, statusCode: Math.trunc(statusCode) }
188
+ }
189
+ }
190
+ if (rawText === '') return undefined
191
+ const truncated = rawText.length > MAX_RAW_CHARS
192
+ return {
193
+ kind: 'raw',
194
+ text: truncated ? rawText.slice(0, MAX_RAW_CHARS) : rawText,
195
+ truncated,
196
+ }
197
+ }
package/src/store.ts CHANGED
@@ -18,6 +18,8 @@ export interface TranscriptStore {
18
18
  subscribe(listener: () => void): () => void
19
19
  /** Fold one session event; ignored events change nothing and notify nobody. */
20
20
  apply(event: SessionEvent): void
21
+ /** Drop the folded view entirely (/clear): the next event starts a fresh one. */
22
+ reset(): void
21
23
  }
22
24
 
23
25
  /**
@@ -49,5 +51,11 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
49
51
  listener()
50
52
  }
51
53
  },
54
+ reset(): void {
55
+ view = createTranscriptView()
56
+ for (const listener of listeners) {
57
+ listener()
58
+ }
59
+ },
52
60
  }
53
61
  }