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.
Files changed (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
@@ -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
- * and tabs survive; everything else in C0/C1 plus DEL becomes a visible
7
- * `\xNN` escape.
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
- * Escape control characters so externally sourced text cannot drive the
17
- * terminal.
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 control character (except `\n`, `\t`) rendered
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.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
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 oldLines = oldText === null ? [] : toLines(oldText)
88
- const newLines = toLines(newText)
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 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 }
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
- 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
- })),
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) && 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,
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) && 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,
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.length > 80 ? `${args.slice(0, 77)}...` : args
49
+ return boundedRawPreview(args)
34
50
  }
@@ -53,21 +53,60 @@ export interface SessionRow {
53
53
  readonly title?: string
54
54
  }
55
55
 
56
- function samePath(left: string | undefined, right: string): boolean {
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 resolve(left).toLowerCase() === resolve(right).toLowerCase()
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.origin === 'subagent' || record.header.parentSession !== undefined
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 currentAgent = agent
73
- if (skills === undefined || currentAgent === undefined) return
72
+ const target = agent
73
+ if (skills === undefined || target === undefined) return
74
74
  Promise.resolve().then(() => skills.list({
75
- cwd: currentAgent.session.header.cwd,
76
- scope: currentAgent,
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 publishes
4
- * {@link TUI_STARTUP_SERVICE} for the runner to consume lazily. Follows the
5
- * headless bundle's startup shape (a commander action publishing a service
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
- * - no flagsa fresh session with a minted id.
17
- *
18
- * @module @deepseek-ai/dsh-tui/startup
19
- */
20
-
21
- import { Command } from 'commander'
22
- import type { Context } from '@deepseek-ai/cordis'
23
- import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
24
-
25
- /** Stable Cordis plugin name. */
26
- export const name = 'tui-startup'
27
-
28
- /** Services required before the invocation can be resolved. */
29
- export const inject = ['cmdlineArgs']
30
-
31
- /** Service provided by this plugin and injected by the terminal runner. */
32
- export const TUI_STARTUP_SERVICE = 'tuiStartup'
33
-
34
- /** How the runner obtains its session identity. */
35
- export type TuiStartup =
36
- | { readonly kind: 'fresh'; readonly mode?: string }
37
- | { readonly kind: 'named'; readonly sessionId: string; readonly mode?: string }
38
- | { readonly kind: 'resume'; readonly sessionId: string }
39
- | { readonly kind: 'latest' }
40
-
41
- export interface TuiStartupOptions {
42
- readonly resume?: string
43
- readonly continue?: boolean
44
- readonly session?: string
45
- readonly mode?: string
46
- }
47
-
48
- /** Pure option policy shared by Commander and tests. */
49
- export function resolveTuiStartup(options: TuiStartupOptions): TuiStartup {
50
- const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
51
- if (selected.filter(Boolean).length > 1) throw new Error('--resume, --continue, and --session are mutually exclusive')
52
- if (options.session === '') throw new Error('--session needs an id')
53
- if (options.resume === '') throw new Error('--resume needs a session id or id prefix')
54
- if (options.mode === '') throw new Error('--mode needs a preset id')
55
- if (options.mode !== undefined && (options.resume !== undefined || options.continue === true)) {
56
- throw new Error('--mode applies only to a new session; it cannot be combined with --resume or --continue')
57
- }
58
- return options.resume !== undefined
59
- ? { kind: 'resume', sessionId: options.resume }
60
- : options.continue === true
61
- ? { kind: 'latest' }
62
- : options.session !== undefined
63
- ? { kind: 'named', sessionId: options.session, ...options.mode === undefined ? {} : { mode: options.mode } }
64
- : { kind: 'fresh', ...options.mode === undefined ? {} : { mode: options.mode } }
65
- }
66
-
67
- /**
68
- * This app's command: the launcher's flags this app owns, its description,
69
- * and its help text.
70
- * @returns a fresh program, so one process can parse more than once (tests).
71
- */
72
- function tuiCommand(): Command {
73
- return new Command()
74
- .name('dsh --profile cli')
75
- .description('Claude-Code-style interactive terminal for DeepSeek Harness.')
76
- .helpOption('-h, --help', 'show this help')
77
- .option('-r, --resume <session>', 'resume the persisted session with this id (or unique id prefix)')
78
- .option('-c, --continue', 'resume the most recent persisted session for this working directory')
79
- .option('--session <id>', 'create a new session under this explicit id')
80
- .option('--mode <preset>', 'agent preset for a newly created session')
81
- .addHelpText('after', `
82
- Examples:
83
- dsh --profile cli fresh session, minted id
84
- dsh --profile cli --resume abc123 resume session by id prefix
85
- dsh --profile cli --continue resume the latest local session
86
- dsh --profile cli --mode minimal fresh session using the minimal preset
87
- `)
88
- }
89
-
90
- /**
91
- * Parse the invocation and publish the startup service. Mutual exclusions are
92
- * usage errors rejected from the action before anything is provided.
93
- * @param ctx - plugin context carrying the command line and exit request.
94
- */
95
- export function apply(ctx: Context): void {
96
- const program = tuiCommand()
97
- program.action(() => {
98
- const options = program.opts<TuiStartupOptions>()
99
- let startup: TuiStartup | undefined
100
- try {
101
- startup = resolveTuiStartup(options)
102
- } catch (error: unknown) {
103
- program.error(`error: ${error instanceof Error ? error.message : String(error)}`)
104
- }
105
- if (startup === undefined) return
106
- ctx.provide(TUI_STARTUP_SERVICE, { startup } satisfies { startup: TuiStartup })
107
- })
108
- parseCmdline(ctx, program)
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
+ }