dsh-code 0.9.1 → 1.0.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 (59) hide show
  1. package/README.en.md +29 -13
  2. package/README.md +264 -248
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +29 -1
  5. package/lib/index.mjs +2223 -687
  6. package/lib/startup.mjs +21 -11
  7. package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
  8. package/lib/types/app.d.ts +66 -14
  9. package/lib/types/attachments.d.ts +7 -0
  10. package/lib/types/editor.d.ts +6 -0
  11. package/lib/types/fork.d.ts +8 -0
  12. package/lib/types/git-workflow.d.ts +23 -0
  13. package/lib/types/index.d.ts +6 -0
  14. package/lib/types/kernel-panels.d.ts +39 -0
  15. package/lib/types/keyboard.d.ts +43 -0
  16. package/lib/types/mentions.d.ts +28 -38
  17. package/lib/types/presets.d.ts +1 -3
  18. package/lib/types/provider-settings.d.ts +16 -0
  19. package/lib/types/render/animations.d.ts +10 -39
  20. package/lib/types/render/editor.d.ts +137 -0
  21. package/lib/types/render/export.d.ts +1 -1
  22. package/lib/types/render/lines.d.ts +6 -2
  23. package/lib/types/render/markdown.d.ts +3 -1
  24. package/lib/types/render/projection.d.ts +29 -3
  25. package/lib/types/render/status.d.ts +5 -12
  26. package/lib/types/session-directory.d.ts +1 -3
  27. package/lib/types/startup.d.ts +14 -11
  28. package/lib/types/store.d.ts +11 -9
  29. package/lib/types/subagents.d.ts +3 -3
  30. package/lib/types/theme.d.ts +14 -1
  31. package/lib/types/version.d.ts +15 -2
  32. package/package.json +153 -141
  33. package/src/app.ts +4455 -3917
  34. package/src/attachments.ts +44 -0
  35. package/src/editor.ts +51 -0
  36. package/src/fork.ts +31 -0
  37. package/src/git-workflow.ts +87 -0
  38. package/src/index.ts +1510 -1374
  39. package/src/internals.ts +14 -1
  40. package/src/kernel-panels.ts +914 -798
  41. package/src/keyboard.ts +125 -0
  42. package/src/mentions.ts +72 -117
  43. package/src/presets.ts +1 -4
  44. package/src/provider-settings.ts +94 -0
  45. package/src/render/animations.ts +25 -55
  46. package/src/render/editor.ts +398 -0
  47. package/src/render/export.ts +79 -79
  48. package/src/render/lines.ts +342 -236
  49. package/src/render/markdown.ts +99 -26
  50. package/src/render/projection.ts +102 -19
  51. package/src/render/status.ts +713 -650
  52. package/src/render/text.ts +150 -150
  53. package/src/render/tool-detail.ts +3 -1
  54. package/src/session-directory.ts +3 -3
  55. package/src/startup.ts +136 -119
  56. package/src/store.ts +23 -11
  57. package/src/subagents.ts +13 -5
  58. package/src/theme.ts +214 -206
  59. package/src/version.ts +58 -1
@@ -1,150 +1,150 @@
1
- /**
2
- * Display-boundary sanitization for externally sourced text (model output,
3
- * tool payloads, skill descriptions). Control characters — including ANSI
4
- * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
- * terminal, letting output rewrite the screen or inject prompts. Newlines
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).
11
- *
12
- * @module @deepseek-ai/dsh-code/render/text
13
- */
14
-
15
- /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
16
- const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
17
-
18
- /**
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).
33
- * @param text - raw text from a session event, tool payload, or catalog.
34
- * @returns display-safe text with every injectable character made visible.
35
- */
36
- export function displayText(text: string): string {
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')}`)
40
- }
41
-
42
- /** Collapse external text to one terminal-safe logical row. */
43
- export function singleLineText(text: string): string {
44
- return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
45
- }
46
-
47
- /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
48
- function cellWidth(text: string): number {
49
- let columns = 0
50
- for (const char of text) {
51
- columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
52
- }
53
- return columns
54
- }
55
-
56
- /**
57
- * Truncate one display-safe row without ever exceeding its physical-column
58
- * budget. The ellipsis is included inside the budget, matching Codex's popup
59
- * truncation contract; the previous app-local helper appended it after the
60
- * row was already full and could force an extra terminal wrap.
61
- */
62
- export function truncateColumns(text: string, columns: number): string {
63
- const limit = Math.max(0, Math.floor(columns))
64
- if (limit === 0) return ''
65
- if (cellWidth(text) <= limit) return text
66
-
67
- const contentLimit = limit - 1
68
- let used = 0
69
- let result = ''
70
- for (const char of text) {
71
- const width = cellWidth(char)
72
- if (used + width > contentLimit) break
73
- result += char
74
- used += width
75
- }
76
- return `${result}…`
77
- }
78
-
79
- /** A display-safe suffix bounded by terminal rows and columns. */
80
- export interface DisplayTail {
81
- /** Sanitized suffix suitable for direct terminal rendering. */
82
- text: string
83
- /** Whether content before the returned suffix was omitted. */
84
- truncated: boolean
85
- }
86
-
87
- /** Read one Unicode character immediately before `end`. */
88
- function previousCharacter(text: string, end: number): { char: string; start: number } {
89
- const last = text.charCodeAt(end - 1)
90
- if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
91
- const first = text.charCodeAt(end - 2)
92
- if (first >= 0xd800 && first <= 0xdbff) {
93
- return { char: text.slice(end - 2, end), start: end - 2 }
94
- }
95
- }
96
- return { char: text.slice(end - 1, end), start: end - 1 }
97
- }
98
-
99
- /**
100
- * Keep only the newest display-safe text that fits a terminal rectangle.
101
- * The scan walks backward and stops as soon as the suffix is full, so a long
102
- * reasoning stream does not rescan its entire accumulated prefix per chunk.
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.
107
- * @param text - raw externally sourced text.
108
- * @param columns - available terminal columns.
109
- * @param rows - available terminal rows.
110
- * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
111
- */
112
- export function displayTail(text: string, columns: number, rows: number): DisplayTail {
113
- const columnLimit = Math.max(1, Math.floor(columns))
114
- const rowLimit = Math.max(1, Math.floor(rows))
115
- const reversed: string[] = []
116
- let row = 1
117
- let used = 0
118
- let end = text.length
119
-
120
- while (end > 0) {
121
- const previous = previousCharacter(text, end)
122
- if (previous.char === '\n') {
123
- if (row >= rowLimit) break
124
- reversed.push('\n')
125
- row += 1
126
- used = 0
127
- end = previous.start
128
- continue
129
- }
130
-
131
- const safe = previous.char === '\t' ? ' ' : displayText(previous.char)
132
- const width = cellWidth(safe)
133
- if (used > 0 && used + width > columnLimit) {
134
- if (row >= rowLimit) break
135
- // Materialize the soft wrap. Ink otherwise reflows at word boundaries
136
- // and can turn a cell-counted two-row suffix into three rendered rows.
137
- reversed.push('\n')
138
- row += 1
139
- used = 0
140
- }
141
- const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
142
- if (row + extraRows > rowLimit) break
143
- row += extraRows
144
- reversed.push(safe)
145
- used = extraRows === 0 ? used + width : width - extraRows * columnLimit
146
- end = previous.start
147
- }
148
-
149
- return { text: reversed.reverse().join(''), truncated: end > 0 }
150
- }
1
+ /**
2
+ * Display-boundary sanitization for externally sourced text (model output,
3
+ * tool payloads, skill descriptions). Control characters — including ANSI
4
+ * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
+ * terminal, letting output rewrite the screen or inject prompts. Newlines
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).
11
+ *
12
+ * @module @deepseek-ai/dsh-code/render/text
13
+ */
14
+
15
+ /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
16
+ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
17
+
18
+ /**
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).
33
+ * @param text - raw text from a session event, tool payload, or catalog.
34
+ * @returns display-safe text with every injectable character made visible.
35
+ */
36
+ export function displayText(text: string): string {
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')}`)
40
+ }
41
+
42
+ /** Collapse external text to one terminal-safe logical row. */
43
+ export function singleLineText(text: string): string {
44
+ return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
45
+ }
46
+
47
+ /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
48
+ function cellWidth(text: string): number {
49
+ let columns = 0
50
+ for (const char of text) {
51
+ columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
52
+ }
53
+ return columns
54
+ }
55
+
56
+ /**
57
+ * Truncate one display-safe row without ever exceeding its physical-column
58
+ * budget. The ellipsis is included inside the budget, matching Codex's popup
59
+ * truncation contract; the previous app-local helper appended it after the
60
+ * row was already full and could force an extra terminal wrap.
61
+ */
62
+ export function truncateColumns(text: string, columns: number): string {
63
+ const limit = Math.max(0, Math.floor(columns))
64
+ if (limit === 0) return ''
65
+ if (cellWidth(text) <= limit) return text
66
+
67
+ const contentLimit = limit - 1
68
+ let used = 0
69
+ let result = ''
70
+ for (const char of text) {
71
+ const width = cellWidth(char)
72
+ if (used + width > contentLimit) break
73
+ result += char
74
+ used += width
75
+ }
76
+ return `${result}…`
77
+ }
78
+
79
+ /** A display-safe suffix bounded by terminal rows and columns. */
80
+ export interface DisplayTail {
81
+ /** Sanitized suffix suitable for direct terminal rendering. */
82
+ text: string
83
+ /** Whether content before the returned suffix was omitted. */
84
+ truncated: boolean
85
+ }
86
+
87
+ /** Read one Unicode character immediately before `end`. */
88
+ function previousCharacter(text: string, end: number): { char: string; start: number } {
89
+ const last = text.charCodeAt(end - 1)
90
+ if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
91
+ const first = text.charCodeAt(end - 2)
92
+ if (first >= 0xd800 && first <= 0xdbff) {
93
+ return { char: text.slice(end - 2, end), start: end - 2 }
94
+ }
95
+ }
96
+ return { char: text.slice(end - 1, end), start: end - 1 }
97
+ }
98
+
99
+ /**
100
+ * Keep only the newest display-safe text that fits a terminal rectangle.
101
+ * The scan walks backward and stops as soon as the suffix is full, so a long
102
+ * reasoning stream does not rescan its entire accumulated prefix per chunk.
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.
107
+ * @param text - raw externally sourced text.
108
+ * @param columns - available terminal columns.
109
+ * @param rows - available terminal rows.
110
+ * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
111
+ */
112
+ export function displayTail(text: string, columns: number, rows: number): DisplayTail {
113
+ const columnLimit = Math.max(1, Math.floor(columns))
114
+ const rowLimit = Math.max(1, Math.floor(rows))
115
+ const reversed: string[] = []
116
+ let row = 1
117
+ let used = 0
118
+ let end = text.length
119
+
120
+ while (end > 0) {
121
+ const previous = previousCharacter(text, end)
122
+ if (previous.char === '\n') {
123
+ if (row >= rowLimit) break
124
+ reversed.push('\n')
125
+ row += 1
126
+ used = 0
127
+ end = previous.start
128
+ continue
129
+ }
130
+
131
+ const safe = previous.char === '\t' ? ' ' : displayText(previous.char)
132
+ const width = cellWidth(safe)
133
+ if (used > 0 && used + width > columnLimit) {
134
+ if (row >= rowLimit) break
135
+ // Materialize the soft wrap. Ink otherwise reflows at word boundaries
136
+ // and can turn a cell-counted two-row suffix into three rendered rows.
137
+ reversed.push('\n')
138
+ row += 1
139
+ used = 0
140
+ }
141
+ const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
142
+ if (row + extraRows > rowLimit) break
143
+ row += extraRows
144
+ reversed.push(safe)
145
+ used = extraRows === 0 ? used + width : width - extraRows * columnLimit
146
+ end = previous.start
147
+ }
148
+
149
+ return { text: reversed.reverse().join(''), truncated: end > 0 }
150
+ }
@@ -11,6 +11,8 @@
11
11
  * @module @deepseek-ai/dsh-code/render/tool-detail
12
12
  */
13
13
 
14
+ import { displayText, truncateColumns } from './text.ts'
15
+
14
16
  /** Budgets keeping one expanded card bounded on a terminal. */
15
17
  const MAX_DIFF_LINES = 200
16
18
  const MAX_READ_LINES = 120
@@ -67,7 +69,7 @@ export type ToolDetail =
67
69
 
68
70
  /** Truncate one line to the visible-column budget with an ellipsis marker. */
69
71
  function clipLine(text: string): string {
70
- return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, MAX_LINE_COLUMNS - 1)}…` : text
72
+ return truncateColumns(displayText(text), MAX_LINE_COLUMNS)
71
73
  }
72
74
 
73
75
  /** Split text into lines, dropping the trailing empty element of a final newline. */
@@ -59,9 +59,9 @@ export interface SessionRow {
59
59
  /** Case-insensitive filesystems (Windows, macOS) compare paths by lowercased form. */
60
60
  const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin'
61
61
 
62
- /** True when the header describes a subagent conversation (durable lineage). */
62
+ /** True only for delegated subagents; ordinary forks also carry lineage. */
63
63
  export function isSubagentSession(header: SessionHeader): boolean {
64
- return header.origin === 'subagent' || header.parentSession !== undefined
64
+ return header.origin === 'subagent'
65
65
  }
66
66
 
67
67
  function comparablePath(value: string): string {
@@ -79,7 +79,7 @@ function comparablePath(value: string): string {
79
79
  }
80
80
 
81
81
  /** Platform-consistent path equality for session cwd comparisons. */
82
- export function samePath(left: string | undefined, right: string): boolean {
82
+ function samePath(left: string | undefined, right: string): boolean {
83
83
  if (left === undefined) return false
84
84
  return comparablePath(left) === comparablePath(right)
85
85
  }
package/src/startup.ts CHANGED
@@ -1,119 +1,136 @@
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
- }
1
+ /**
2
+ * The interactive terminal app's command-line provider: parses `--resume`,
3
+ * `--continue`, `--session`, `--mode`, `--theme`, `--image`, an optional
4
+ * initial prompt, and `--help`, then
5
+ * publishes {@link TUI_STARTUP_SERVICE} for the runner to consume lazily.
6
+ * Follows the headless bundle's startup shape (a commander action publishing
7
+ * a service through {@link parseCmdline}).
8
+ *
9
+ * Semantics:
10
+ * - `--resume <id|prefix>` continue the persisted session whose id or unique
11
+ * id-prefix matches; the TUI replays its transcript and appends to the same
12
+ * durable log.
13
+ * - `--continue` / `-c` — resume the most recently modified persisted session
14
+ * whose project directory matches the current working directory.
15
+ * - `--session <id>` create a new session under an explicit identity (the
16
+ * id must not exist yet).
17
+ * - `--theme <dark|light|auto>` the color palette; auto follows the
18
+ * terminal (dark fallback until OSC-11 detection lands).
19
+ * - no flags — a fresh session with a minted id.
20
+ *
21
+ * @module @deepseek-ai/dsh-tui/startup
22
+ */
23
+
24
+ import { Command } from 'commander'
25
+ import type { Context } from '@deepseek-ai/cordis'
26
+ import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
27
+ import { THEME_NAMES, type ThemeName } from './theme.ts'
28
+
29
+ /** Stable Cordis plugin name. */
30
+ export const name = 'tui-startup'
31
+
32
+ /** Services required before the invocation can be resolved. */
33
+ export const inject = ['cmdlineArgs']
34
+
35
+ /** Service provided by this plugin and injected by the terminal runner. */
36
+ const TUI_STARTUP_SERVICE = 'tuiStartup'
37
+
38
+ /** How the runner obtains its session identity. */
39
+ export type TuiStartup =
40
+ | ({ readonly kind: 'fresh'; readonly mode?: string } & TuiStartupInput)
41
+ | ({ readonly kind: 'named'; readonly sessionId: string; readonly mode?: string } & TuiStartupInput)
42
+ | ({ readonly kind: 'resume'; readonly sessionId: string } & TuiStartupInput)
43
+ | ({ readonly kind: 'latest' } & TuiStartupInput)
44
+
45
+ interface TuiStartupInput {
46
+ readonly theme?: ThemeName
47
+ readonly prompt?: string
48
+ readonly images?: readonly string[]
49
+ }
50
+
51
+ export interface TuiStartupOptions {
52
+ readonly resume?: string
53
+ readonly continue?: boolean
54
+ readonly session?: string
55
+ readonly mode?: string
56
+ readonly theme?: ThemeName
57
+ readonly prompt?: string
58
+ readonly images?: readonly string[]
59
+ }
60
+
61
+ /** Pure option policy shared by Commander and tests. */
62
+ export function resolveTuiStartup(options: TuiStartupOptions): TuiStartup {
63
+ const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
64
+ if (selected.filter(Boolean).length > 1) throw new Error('--resume, --continue, and --session are mutually exclusive')
65
+ if (options.session === '') throw new Error('--session needs an id')
66
+ if (options.resume === '') throw new Error('--resume needs a session id or id prefix')
67
+ if (options.mode === '') throw new Error('--mode needs a preset id')
68
+ if (options.mode !== undefined && (options.resume !== undefined || options.continue === true)) {
69
+ throw new Error('--mode applies only to a new session; it cannot be combined with --resume or --continue')
70
+ }
71
+ if (options.theme !== undefined && !THEME_NAMES.includes(options.theme)) {
72
+ throw new Error('--theme must be dark, light, or auto')
73
+ }
74
+ const input = {
75
+ ...(options.theme === undefined ? {} : { theme: options.theme }),
76
+ ...(options.prompt === undefined || options.prompt.trim() === '' ? {} : { prompt: options.prompt.trim() }),
77
+ ...(options.images === undefined || options.images.length === 0 ? {} : { images: [...options.images] }),
78
+ }
79
+ return options.resume !== undefined
80
+ ? { kind: 'resume', sessionId: options.resume, ...input }
81
+ : options.continue === true
82
+ ? { kind: 'latest', ...input }
83
+ : options.session !== undefined
84
+ ? { kind: 'named', sessionId: options.session, ...options.mode === undefined ? {} : { mode: options.mode }, ...input }
85
+ : { kind: 'fresh', ...options.mode === undefined ? {} : { mode: options.mode }, ...input }
86
+ }
87
+
88
+ /**
89
+ * This app's command: the launcher's flags this app owns, its description,
90
+ * and its help text.
91
+ * @returns a fresh program, so one process can parse more than once (tests).
92
+ */
93
+ function tuiCommand(): Command {
94
+ return new Command()
95
+ .name('dsh --profile cli')
96
+ .description('DeepSeek Harness CLI core: the interactive coding terminal.')
97
+ .helpOption('-h, --help', 'show this help')
98
+ .option('-r, --resume <session>', 'resume the persisted session with this id (or unique id prefix)')
99
+ .option('-c, --continue', 'resume the most recent persisted session for this working directory')
100
+ .option('--session <id>', 'create a new session under this explicit id')
101
+ .option('--mode <preset>', 'agent preset for a newly created session')
102
+ .option('--theme <name>', 'color theme: dark (default), light, or auto')
103
+ .option('-i, --image <path>', 'attach an image to the initial prompt (repeatable)', (path, paths: string[]) => [...paths, path], [])
104
+ .argument('[prompt...]', 'initial prompt; sends immediately after startup')
105
+ .addHelpText('after', `
106
+ Examples:
107
+ dsh --profile cli fresh session, minted id
108
+ dsh --profile cli --resume abc123 resume session by id prefix
109
+ dsh --profile cli --continue resume the latest local session
110
+ dsh --profile cli --mode minimal fresh session using the minimal preset
111
+ dsh --profile cli --theme light light palette for bright terminals
112
+ dsh --profile cli "explain this repo" start and send an initial prompt
113
+ dsh --profile cli -i diagram.png "review this diagram"
114
+ `)
115
+ }
116
+
117
+ /**
118
+ * Parse the invocation and publish the startup service. Mutual exclusions are
119
+ * usage errors rejected from the action before anything is provided.
120
+ * @param ctx - plugin context carrying the command line and exit request.
121
+ */
122
+ export function apply(ctx: Context): void {
123
+ const program = tuiCommand()
124
+ program.action((prompt: string[]) => {
125
+ const options = { ...program.opts<TuiStartupOptions>(), prompt: prompt.join(' ') }
126
+ let startup: TuiStartup | undefined
127
+ try {
128
+ startup = resolveTuiStartup(options)
129
+ } catch (error: unknown) {
130
+ program.error(`error: ${error instanceof Error ? error.message : String(error)}`)
131
+ }
132
+ if (startup === undefined) return
133
+ ctx.provide(TUI_STARTUP_SERVICE, { startup } satisfies { startup: TuiStartup })
134
+ })
135
+ parseCmdline(ctx, program)
136
+ }