dsh-code 0.6.1 → 0.8.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.en.md +20 -6
- package/README.md +20 -6
- package/lib/index.mjs +3952 -1315
- package/lib/startup.mjs +21 -9
- package/lib/theme-BEi4i_aN.mjs +624 -0
- package/lib/types/app.d.ts +108 -4
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +49 -0
- package/lib/types/kernel-panels.d.ts +28 -0
- package/lib/types/mentions.d.ts +29 -12
- package/lib/types/models.d.ts +66 -0
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +177 -2
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +123 -3
- package/lib/types/render/status.d.ts +35 -24
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +4 -1
- package/lib/types/session-directory.d.ts +15 -0
- package/lib/types/startup.d.ts +12 -4
- package/lib/types/store.d.ts +13 -2
- package/lib/types/theme-panel.d.ts +24 -0
- package/lib/types/theme.d.ts +158 -2
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +1283 -206
- package/src/approval.ts +11 -2
- package/src/history.ts +20 -5
- package/src/index.ts +1207 -905
- package/src/kernel-panels.ts +518 -419
- package/src/mentions.ts +57 -27
- package/src/models.ts +200 -66
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +373 -2
- package/src/render/lines.ts +21 -6
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +1419 -659
- package/src/render/status.ts +650 -603
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +18 -2
- package/src/session-directory.ts +44 -5
- package/src/skills.ts +8 -4
- package/src/startup.ts +119 -109
- package/src/store.ts +26 -8
- package/src/theme-panel.ts +72 -0
- package/src/theme.ts +206 -70
- package/src/version.ts +16 -0
package/src/render/text.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* tool payloads, skill descriptions). Control characters — including ANSI
|
|
4
4
|
* CSI/OSC escape sequences — would otherwise pass through Ink into the
|
|
5
5
|
* terminal, letting output rewrite the screen or inject prompts. Newlines
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* survive; everything else in C0/C1 plus DEL becomes a visible `\xNN`
|
|
7
|
+
* escape, and bidi overrides / invisible format controls / Unicode line and
|
|
8
|
+
* paragraph separators become a visible `\uXXXX` escape (terminal emulators
|
|
9
|
+
* that render bidirectional text would otherwise reorder the displayed
|
|
10
|
+
* glyphs and let a command read as something it is not).
|
|
8
11
|
*
|
|
9
12
|
* @module @deepseek-ai/dsh-code/render/text
|
|
10
13
|
*/
|
|
@@ -13,14 +16,27 @@
|
|
|
13
16
|
const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* Bidi overrides and isolates (U+202A-202E, U+2066-2069), the Arabic Letter
|
|
20
|
+
* Mark (U+061C), directional and zero-width format characters (U+200B,
|
|
21
|
+
* U+200E/200F, U+2060-2064, U+FEFF), and Unicode line/paragraph separators
|
|
22
|
+
* (U+2028/2029). Terminal emulators with bidi support (Windows Terminal,
|
|
23
|
+
* iTerm2, kitty, WezTerm) reorder or hide these, so they must never reach
|
|
24
|
+
* the terminal raw.
|
|
25
|
+
*/
|
|
26
|
+
const INVISIBLE_ESCAPE = /[\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Escape control and deceptive characters so externally sourced text cannot
|
|
30
|
+
* drive the terminal. C0/C1/DEL render as a literal `\xNN` escape; bidi,
|
|
31
|
+
* invisible-format, and separator controls render as a literal `\uXXXX`
|
|
32
|
+
* escape. Newlines and tabs survive (budgeted callers normalize tabs).
|
|
18
33
|
* @param text - raw text from a session event, tool payload, or catalog.
|
|
19
|
-
* @returns text with every
|
|
20
|
-
* as a literal `\xNN` escape.
|
|
34
|
+
* @returns display-safe text with every injectable character made visible.
|
|
21
35
|
*/
|
|
22
36
|
export function displayText(text: string): string {
|
|
23
|
-
return text
|
|
37
|
+
return text
|
|
38
|
+
.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
39
|
+
.replace(INVISIBLE_ESCAPE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/** Collapse external text to one terminal-safe logical row. */
|
|
@@ -84,7 +100,10 @@ function previousCharacter(text: string, end: number): { char: string; start: nu
|
|
|
84
100
|
* Keep only the newest display-safe text that fits a terminal rectangle.
|
|
85
101
|
* The scan walks backward and stops as soon as the suffix is full, so a long
|
|
86
102
|
* reasoning stream does not rescan its entire accumulated prefix per chunk.
|
|
87
|
-
* Explicit newlines and terminal wrapping both consume rows
|
|
103
|
+
* Explicit newlines and terminal wrapping both consume rows; tabs expand to
|
|
104
|
+
* two spaces so terminal tab stops (which render at contextual column 8
|
|
105
|
+
* boundaries, not at the budgeted cell count) cannot inflate the physical
|
|
106
|
+
* row count of the live region.
|
|
88
107
|
* @param text - raw externally sourced text.
|
|
89
108
|
* @param columns - available terminal columns.
|
|
90
109
|
* @param rows - available terminal rows.
|
|
@@ -109,7 +128,7 @@ export function displayTail(text: string, columns: number, rows: number): Displa
|
|
|
109
128
|
continue
|
|
110
129
|
}
|
|
111
130
|
|
|
112
|
-
const safe = displayText(previous.char)
|
|
131
|
+
const safe = previous.char === '\t' ? ' ' : displayText(previous.char)
|
|
113
132
|
const width = cellWidth(safe)
|
|
114
133
|
if (used > 0 && used + width > columnLimit) {
|
|
115
134
|
if (row >= rowLimit) break
|
|
@@ -17,6 +17,9 @@ const MAX_READ_LINES = 120
|
|
|
17
17
|
const MAX_SOURCES = 10
|
|
18
18
|
const MAX_RAW_CHARS = 6000
|
|
19
19
|
const MAX_LINE_COLUMNS = 240
|
|
20
|
+
/** Hard caps on adversarial `tool/result.meta` before any row is built. */
|
|
21
|
+
const MAX_DIFFS = 8
|
|
22
|
+
const MAX_DIFF_TEXT_CHARS = 24_000
|
|
20
23
|
|
|
21
24
|
/** One rendered diff row: removed, added, or shared context. */
|
|
22
25
|
export interface DiffLine {
|
|
@@ -77,15 +80,32 @@ function toLines(text: string): string[] {
|
|
|
77
80
|
* Render one change as removed-then-added rows, hunked by common prefix and
|
|
78
81
|
* suffix. A null before-image (file create) renders as pure additions. The
|
|
79
82
|
* budget caps emitted rows and reports the cut, so a whole-file overwrite
|
|
80
|
-
* never floods the transcript.
|
|
83
|
+
* never floods the transcript. Inputs are hard-capped before line splitting
|
|
84
|
+
* and the row list is built incrementally up to the budget — a crafted or
|
|
85
|
+
* replayed giant diff cannot force a full intermediate rows array.
|
|
81
86
|
* @param oldText - prior content, or null for a create.
|
|
82
87
|
* @param newText - content after the change.
|
|
83
88
|
* @param budget - maximum rows to emit.
|
|
84
89
|
* @returns the bounded rows and whether they were cut.
|
|
85
90
|
*/
|
|
86
91
|
export function diffRows(oldText: string | null, newText: string, budget: number): { lines: readonly DiffLine[]; truncated: boolean } {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
92
|
+
const oldRaw = oldText ?? ''
|
|
93
|
+
const newRaw = newText
|
|
94
|
+
let inputTruncated = false
|
|
95
|
+
let oldSource = oldRaw
|
|
96
|
+
let newSource = newRaw
|
|
97
|
+
// Bound the working arrays before `toLines` allocates them: keep a
|
|
98
|
+
// combined-characters share of each side proportional to its input size.
|
|
99
|
+
const combined = oldRaw.length + newRaw.length
|
|
100
|
+
if (combined > MAX_DIFF_TEXT_CHARS) {
|
|
101
|
+
inputTruncated = true
|
|
102
|
+
const oldShare = Math.min(oldRaw.length, Math.floor(MAX_DIFF_TEXT_CHARS * oldRaw.length / combined))
|
|
103
|
+
const newShare = Math.min(newRaw.length, MAX_DIFF_TEXT_CHARS - oldShare)
|
|
104
|
+
oldSource = oldRaw.slice(0, oldShare)
|
|
105
|
+
newSource = newRaw.slice(0, newShare)
|
|
106
|
+
}
|
|
107
|
+
const oldLines = oldText === null ? [] : toLines(oldSource)
|
|
108
|
+
const newLines = toLines(newSource)
|
|
89
109
|
let prefix = 0
|
|
90
110
|
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1
|
|
91
111
|
let suffix = 0
|
|
@@ -93,14 +113,20 @@ export function diffRows(oldText: string | null, newText: string, budget: number
|
|
|
93
113
|
suffix < oldLines.length - prefix && suffix < newLines.length - prefix
|
|
94
114
|
&& oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
95
115
|
) suffix += 1
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
const removedCount = oldLines.length - prefix - suffix
|
|
117
|
+
const addedCount = newLines.length - prefix - suffix
|
|
118
|
+
const truncated = inputTruncated || removedCount + addedCount > budget
|
|
119
|
+
// Build at most `budget` rows directly; never materialize the full hunk.
|
|
120
|
+
const rows: DiffLine[] = []
|
|
121
|
+
const removedLimit = Math.min(removedCount, Math.max(0, budget))
|
|
122
|
+
for (let index = 0; index < removedLimit; index += 1) {
|
|
123
|
+
rows.push({ mark: '-', text: clipLine(oldLines[prefix + index] ?? '') })
|
|
124
|
+
}
|
|
125
|
+
const addedLimit = Math.min(addedCount, Math.max(0, budget - removedLimit))
|
|
126
|
+
for (let index = 0; index < addedLimit; index += 1) {
|
|
127
|
+
rows.push({ mark: '+', text: clipLine(newLines[prefix + index] ?? '') })
|
|
128
|
+
}
|
|
129
|
+
return { lines: rows, truncated }
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
/** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
|
|
@@ -140,45 +166,60 @@ export function toolResultDetail(meta: unknown, rawText: string): ToolDetail | u
|
|
|
140
166
|
const record = meta as Record<string, unknown>
|
|
141
167
|
|
|
142
168
|
const diffs = record['diffs']
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
// Validate and process only the capped prefix: an adversarial meta with
|
|
170
|
+
// thousands of diffs never runs `.every` over the full array.
|
|
171
|
+
if (Array.isArray(diffs) && diffs.length > 0) {
|
|
172
|
+
const capped = diffs.slice(0, MAX_DIFFS)
|
|
173
|
+
if (capped.every(isFileDiff)) {
|
|
174
|
+
const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / capped.length))
|
|
175
|
+
// Diffs dropped beyond the cap must not vanish silently: the last
|
|
176
|
+
// kept diff reports the cut exactly like a hunk cut does.
|
|
177
|
+
const dropped = diffs.length > capped.length
|
|
178
|
+
const rendered = capped.map((diff, index) => {
|
|
179
|
+
const rows = diffRows(diff.oldText, diff.newText, budget)
|
|
180
|
+
return dropped && index === capped.length - 1
|
|
181
|
+
? { path: diff.path, ...rows, truncated: true }
|
|
182
|
+
: { path: diff.path, ...rows }
|
|
183
|
+
})
|
|
184
|
+
return { kind: 'diff', diffs: rendered }
|
|
151
185
|
}
|
|
152
186
|
}
|
|
153
187
|
|
|
154
188
|
const { path, offset, lines, totalLines } = record
|
|
155
189
|
if (typeof path === 'string' && Number.isInteger(offset) && (offset as number) >= 1
|
|
156
190
|
&& Number.isInteger(totalLines) && (totalLines as number) >= 0
|
|
157
|
-
&& Array.isArray(lines)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
191
|
+
&& Array.isArray(lines)) {
|
|
192
|
+
// Validate the bounded window only: lines beyond the display cap are
|
|
193
|
+
// dropped anyway, so a giant persisted window cannot force a full-array
|
|
194
|
+
// validation pass before the slice.
|
|
195
|
+
const window = lines.slice(0, MAX_READ_LINES)
|
|
196
|
+
if (window.every(isReadLine)) {
|
|
197
|
+
const truncated = lines.length > MAX_READ_LINES
|
|
198
|
+
return {
|
|
199
|
+
kind: 'read',
|
|
200
|
+
path,
|
|
201
|
+
offset: offset as number,
|
|
202
|
+
lines: window.map(line => ({ number: line.number, text: clipLine(line.text) })),
|
|
203
|
+
totalLines: totalLines as number,
|
|
204
|
+
truncated,
|
|
205
|
+
}
|
|
168
206
|
}
|
|
169
207
|
}
|
|
170
208
|
|
|
171
209
|
const sources = record['sources']
|
|
172
|
-
if (Array.isArray(sources)
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
210
|
+
if (Array.isArray(sources)) {
|
|
211
|
+
const capped = sources.slice(0, MAX_SOURCES)
|
|
212
|
+
if (capped.every(isWebSource)) {
|
|
213
|
+
const truncated = sources.length > MAX_SOURCES
|
|
214
|
+
return {
|
|
215
|
+
kind: 'web-search',
|
|
216
|
+
sources: capped.map(source => ({
|
|
217
|
+
url: source.url,
|
|
218
|
+
title: typeof source.title === 'string' ? source.title : undefined,
|
|
219
|
+
snippet: typeof source.snippet === 'string' ? clipLine(source.snippet) : '',
|
|
220
|
+
})),
|
|
221
|
+
truncated,
|
|
222
|
+
}
|
|
182
223
|
}
|
|
183
224
|
}
|
|
184
225
|
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
3
3
|
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
4
4
|
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
5
|
-
* transcript and the approval bar's command preview.
|
|
5
|
+
* transcript and the approval bar's command preview. Arguments longer than
|
|
6
|
+
* {@link MAX_PARSE_CHARS} are never parsed: the preview is a display concern,
|
|
7
|
+
* and a synchronous `JSON.parse` plus string copies of an unbounded model
|
|
8
|
+
* payload must not run on the approval or projection paths.
|
|
6
9
|
*
|
|
7
10
|
* @module @deepseek-ai/dsh-code/render/tool-preview
|
|
8
11
|
*/
|
|
@@ -10,6 +13,18 @@
|
|
|
10
13
|
/** Keys searched in declaration order when building a preview. */
|
|
11
14
|
const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Raw arguments longer than this are skipped without parsing and fall back
|
|
18
|
+
* to the bounded raw preview. Well above any realistic command/path/query
|
|
19
|
+
* string while keeping the synchronous parse cost negligible.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_PARSE_CHARS = 4096
|
|
22
|
+
|
|
23
|
+
/** Bounded raw-arguments fallback shared by the skip-parse and parse-failure paths. */
|
|
24
|
+
function boundedRawPreview(args: string): string {
|
|
25
|
+
return args.length > 80 ? `${args.slice(0, 77)}...` : args
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Resolve one bounded preview for raw tool arguments.
|
|
15
30
|
* @param args - raw JSON arguments string as the model produced it.
|
|
@@ -18,6 +33,7 @@ const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query
|
|
|
18
33
|
*/
|
|
19
34
|
export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
20
35
|
if (args === '') return toolName
|
|
36
|
+
if (args.length > MAX_PARSE_CHARS) return boundedRawPreview(args)
|
|
21
37
|
try {
|
|
22
38
|
const parsed: unknown = JSON.parse(args)
|
|
23
39
|
if (parsed !== null && typeof parsed === 'object') {
|
|
@@ -30,5 +46,5 @@ export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
|
30
46
|
} catch {
|
|
31
47
|
// Raw JSON parse failed: fall through to the bounded raw arguments.
|
|
32
48
|
}
|
|
33
|
-
return args
|
|
49
|
+
return boundedRawPreview(args)
|
|
34
50
|
}
|
package/src/session-directory.ts
CHANGED
|
@@ -53,21 +53,60 @@ export interface SessionRow {
|
|
|
53
53
|
readonly title?: string
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/** Case-insensitive filesystems (Windows, macOS) compare paths by lowercased form. */
|
|
57
|
+
const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin'
|
|
58
|
+
|
|
59
|
+
/** True when the header describes a subagent conversation (durable lineage). */
|
|
60
|
+
export function isSubagentSession(header: SessionHeader): boolean {
|
|
61
|
+
return header.origin === 'subagent' || header.parentSession !== undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function comparablePath(value: string): string {
|
|
65
|
+
const resolved = resolve(value)
|
|
66
|
+
return CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Platform-consistent path equality for session cwd comparisons. */
|
|
70
|
+
export function samePath(left: string | undefined, right: string): boolean {
|
|
57
71
|
if (left === undefined) return false
|
|
58
|
-
return
|
|
72
|
+
return comparablePath(left) === comparablePath(right)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Unique header match by exact id or unique id prefix (root and subagent
|
|
77
|
+
* headers alike); the caller applies any lineage gate.
|
|
78
|
+
* @param headers - the persisted headers.
|
|
79
|
+
* @param wanted - the id or id prefix.
|
|
80
|
+
* @returns the uniquely matched header.
|
|
81
|
+
* @throws when nothing matches or the prefix is ambiguous.
|
|
82
|
+
*/
|
|
83
|
+
export function matchSessionId(headers: readonly SessionHeader[], wanted: string): SessionHeader {
|
|
84
|
+
const exact = headers.filter(header => header.id === wanted)
|
|
85
|
+
const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
|
|
86
|
+
if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
|
|
87
|
+
if (matches.length > 1) {
|
|
88
|
+
throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
|
|
89
|
+
}
|
|
90
|
+
return matches[0]!
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The newest persisted ROOT session pinned to this cwd, or undefined. */
|
|
94
|
+
export function newestRootForCwd(headers: readonly SessionHeader[], cwd: string): SessionHeader | undefined {
|
|
95
|
+
const local = headers
|
|
96
|
+
.filter(header => !isSubagentSession(header) && samePath(header.cwd, cwd))
|
|
97
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
98
|
+
return local[0]
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
/** Filter/sort header-only records. No session log is loaded here. */
|
|
62
102
|
export function projectSessionRows(records: readonly SessionRecord[], options: SessionDirectoryOptions): SessionRow[] {
|
|
63
103
|
const needle = options.query.trim().toLowerCase()
|
|
64
104
|
return records
|
|
65
|
-
.filter(record => options.sessions === 'all'
|
|
66
|
-
|| (record.header.parentSession === undefined && record.header.origin !== 'subagent'))
|
|
105
|
+
.filter(record => options.sessions === 'all' || !isSubagentSession(record.header))
|
|
67
106
|
.filter(record => options.cwd === 'all' || samePath(record.header.cwd, options.currentCwd))
|
|
68
107
|
.map(record => {
|
|
69
108
|
const cwd = record.header.cwd ?? ''
|
|
70
|
-
const subagent = record.header
|
|
109
|
+
const subagent = isSubagentSession(record.header)
|
|
71
110
|
return {
|
|
72
111
|
id: record.header.id,
|
|
73
112
|
createdAt: record.header.createdAt,
|
package/src/skills.ts
CHANGED
|
@@ -69,12 +69,15 @@ export function watchSkills(ctx: Context): SkillsWatch {
|
|
|
69
69
|
const listeners = new Set<() => void>()
|
|
70
70
|
|
|
71
71
|
const reload = (): void => {
|
|
72
|
-
const
|
|
73
|
-
if (skills === undefined ||
|
|
72
|
+
const target = agent
|
|
73
|
+
if (skills === undefined || target === undefined) return
|
|
74
74
|
Promise.resolve().then(() => skills.list({
|
|
75
|
-
cwd:
|
|
76
|
-
scope:
|
|
75
|
+
cwd: target.session.header.cwd,
|
|
76
|
+
scope: target,
|
|
77
77
|
})).then((summaries: readonly SkillSummary[]) => {
|
|
78
|
+
// A retarget landed while this catalog was loading: the rows belong to
|
|
79
|
+
// another agent's workspace and must never overwrite the current view.
|
|
80
|
+
if (agent !== target) return
|
|
78
81
|
const next = toRows(summaries)
|
|
79
82
|
const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)
|
|
80
83
|
rows = next
|
|
@@ -83,6 +86,7 @@ export function watchSkills(ctx: Context): SkillsWatch {
|
|
|
83
86
|
if (unchanged && !recovered) return
|
|
84
87
|
for (const listener of listeners) listener()
|
|
85
88
|
}).catch((cause: unknown) => {
|
|
89
|
+
if (agent !== target) return
|
|
86
90
|
// Discovery failure keeps the last good rows; the next skills/change
|
|
87
91
|
// notification is the retry surface (mirrors the web directory).
|
|
88
92
|
rows = [...rows]
|
package/src/startup.ts
CHANGED
|
@@ -1,109 +1,119 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The interactive terminal app's command-line provider: parses `--resume`,
|
|
3
|
-
* `--continue`, `--session`, `--mode`, and `--help`, then
|
|
4
|
-
* {@link TUI_STARTUP_SERVICE} for the runner to consume lazily.
|
|
5
|
-
* headless bundle's startup shape (a commander action publishing
|
|
6
|
-
* through {@link parseCmdline}).
|
|
7
|
-
*
|
|
8
|
-
* Semantics:
|
|
9
|
-
* - `--resume <id|prefix>` — continue the persisted session whose id or unique
|
|
10
|
-
* id-prefix matches; the TUI replays its transcript and appends to the same
|
|
11
|
-
* durable log.
|
|
12
|
-
* - `--continue` / `-c` — resume the most recently modified persisted session
|
|
13
|
-
* whose project directory matches the current working directory.
|
|
14
|
-
* - `--session <id>` — create a new session under an explicit identity (the
|
|
15
|
-
* id must not exist yet).
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
export const
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
export const
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
| { readonly kind: '
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
readonly
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
readonly
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The interactive terminal app's command-line provider: parses `--resume`,
|
|
3
|
+
* `--continue`, `--session`, `--mode`, `--theme`, and `--help`, then
|
|
4
|
+
* publishes {@link TUI_STARTUP_SERVICE} for the runner to consume lazily.
|
|
5
|
+
* Follows the headless bundle's startup shape (a commander action publishing
|
|
6
|
+
* a service through {@link parseCmdline}).
|
|
7
|
+
*
|
|
8
|
+
* Semantics:
|
|
9
|
+
* - `--resume <id|prefix>` — continue the persisted session whose id or unique
|
|
10
|
+
* id-prefix matches; the TUI replays its transcript and appends to the same
|
|
11
|
+
* durable log.
|
|
12
|
+
* - `--continue` / `-c` — resume the most recently modified persisted session
|
|
13
|
+
* whose project directory matches the current working directory.
|
|
14
|
+
* - `--session <id>` — create a new session under an explicit identity (the
|
|
15
|
+
* id must not exist yet).
|
|
16
|
+
* - `--theme <dark|light|auto>` — the color palette; auto follows the
|
|
17
|
+
* terminal (dark fallback until OSC-11 detection lands).
|
|
18
|
+
* - no flags — a fresh session with a minted id.
|
|
19
|
+
*
|
|
20
|
+
* @module @deepseek-ai/dsh-tui/startup
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Command } from 'commander'
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
25
|
+
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
|
|
26
|
+
import { THEME_NAMES, type ThemeName } from './theme.ts'
|
|
27
|
+
|
|
28
|
+
/** Stable Cordis plugin name. */
|
|
29
|
+
export const name = 'tui-startup'
|
|
30
|
+
|
|
31
|
+
/** Services required before the invocation can be resolved. */
|
|
32
|
+
export const inject = ['cmdlineArgs']
|
|
33
|
+
|
|
34
|
+
/** Service provided by this plugin and injected by the terminal runner. */
|
|
35
|
+
export const TUI_STARTUP_SERVICE = 'tuiStartup'
|
|
36
|
+
|
|
37
|
+
/** How the runner obtains its session identity. */
|
|
38
|
+
export type TuiStartup =
|
|
39
|
+
| { readonly kind: 'fresh'; readonly mode?: string; readonly theme?: ThemeName }
|
|
40
|
+
| { readonly kind: 'named'; readonly sessionId: string; readonly mode?: string; readonly theme?: ThemeName }
|
|
41
|
+
| { readonly kind: 'resume'; readonly sessionId: string; readonly theme?: ThemeName }
|
|
42
|
+
| { readonly kind: 'latest'; readonly theme?: ThemeName }
|
|
43
|
+
|
|
44
|
+
export interface TuiStartupOptions {
|
|
45
|
+
readonly resume?: string
|
|
46
|
+
readonly continue?: boolean
|
|
47
|
+
readonly session?: string
|
|
48
|
+
readonly mode?: string
|
|
49
|
+
readonly theme?: ThemeName
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pure option policy shared by Commander and tests. */
|
|
53
|
+
export function resolveTuiStartup(options: TuiStartupOptions): TuiStartup {
|
|
54
|
+
const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
|
|
55
|
+
if (selected.filter(Boolean).length > 1) throw new Error('--resume, --continue, and --session are mutually exclusive')
|
|
56
|
+
if (options.session === '') throw new Error('--session needs an id')
|
|
57
|
+
if (options.resume === '') throw new Error('--resume needs a session id or id prefix')
|
|
58
|
+
if (options.mode === '') throw new Error('--mode needs a preset id')
|
|
59
|
+
if (options.mode !== undefined && (options.resume !== undefined || options.continue === true)) {
|
|
60
|
+
throw new Error('--mode applies only to a new session; it cannot be combined with --resume or --continue')
|
|
61
|
+
}
|
|
62
|
+
if (options.theme !== undefined && !THEME_NAMES.includes(options.theme)) {
|
|
63
|
+
throw new Error('--theme must be dark, light, or auto')
|
|
64
|
+
}
|
|
65
|
+
const theme = options.theme === undefined ? {} : { theme: options.theme }
|
|
66
|
+
return options.resume !== undefined
|
|
67
|
+
? { kind: 'resume', sessionId: options.resume, ...theme }
|
|
68
|
+
: options.continue === true
|
|
69
|
+
? { kind: 'latest', ...theme }
|
|
70
|
+
: options.session !== undefined
|
|
71
|
+
? { kind: 'named', sessionId: options.session, ...options.mode === undefined ? {} : { mode: options.mode }, ...theme }
|
|
72
|
+
: { kind: 'fresh', ...options.mode === undefined ? {} : { mode: options.mode }, ...theme }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* This app's command: the launcher's flags this app owns, its description,
|
|
77
|
+
* and its help text.
|
|
78
|
+
* @returns a fresh program, so one process can parse more than once (tests).
|
|
79
|
+
*/
|
|
80
|
+
function tuiCommand(): Command {
|
|
81
|
+
return new Command()
|
|
82
|
+
.name('dsh --profile cli')
|
|
83
|
+
.description('Claude-Code-style interactive terminal for DeepSeek Harness.')
|
|
84
|
+
.helpOption('-h, --help', 'show this help')
|
|
85
|
+
.option('-r, --resume <session>', 'resume the persisted session with this id (or unique id prefix)')
|
|
86
|
+
.option('-c, --continue', 'resume the most recent persisted session for this working directory')
|
|
87
|
+
.option('--session <id>', 'create a new session under this explicit id')
|
|
88
|
+
.option('--mode <preset>', 'agent preset for a newly created session')
|
|
89
|
+
.option('--theme <name>', 'color theme: dark (default), light, or auto')
|
|
90
|
+
.addHelpText('after', `
|
|
91
|
+
Examples:
|
|
92
|
+
dsh --profile cli fresh session, minted id
|
|
93
|
+
dsh --profile cli --resume abc123 resume session by id prefix
|
|
94
|
+
dsh --profile cli --continue resume the latest local session
|
|
95
|
+
dsh --profile cli --mode minimal fresh session using the minimal preset
|
|
96
|
+
dsh --profile cli --theme light light palette for bright terminals
|
|
97
|
+
`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse the invocation and publish the startup service. Mutual exclusions are
|
|
102
|
+
* usage errors rejected from the action before anything is provided.
|
|
103
|
+
* @param ctx - plugin context carrying the command line and exit request.
|
|
104
|
+
*/
|
|
105
|
+
export function apply(ctx: Context): void {
|
|
106
|
+
const program = tuiCommand()
|
|
107
|
+
program.action(() => {
|
|
108
|
+
const options = program.opts<TuiStartupOptions>()
|
|
109
|
+
let startup: TuiStartup | undefined
|
|
110
|
+
try {
|
|
111
|
+
startup = resolveTuiStartup(options)
|
|
112
|
+
} catch (error: unknown) {
|
|
113
|
+
program.error(`error: ${error instanceof Error ? error.message : String(error)}`)
|
|
114
|
+
}
|
|
115
|
+
if (startup === undefined) return
|
|
116
|
+
ctx.provide(TUI_STARTUP_SERVICE, { startup } satisfies { startup: TuiStartup })
|
|
117
|
+
})
|
|
118
|
+
parseCmdline(ctx, program)
|
|
119
|
+
}
|