dsh-code 1.0.7 → 1.3.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 (89) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +5002 -1061
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
  9. package/lib/types/app.d.ts +120 -63
  10. package/lib/types/attachments.d.ts +16 -7
  11. package/lib/types/authorization-panel.d.ts +3 -3
  12. package/lib/types/git-workflow.d.ts +91 -2
  13. package/lib/types/history.d.ts +10 -0
  14. package/lib/types/i18n.d.ts +39 -0
  15. package/lib/types/index.d.ts +74 -2
  16. package/lib/types/input-split.d.ts +1 -1
  17. package/lib/types/kernel-panels.d.ts +89 -32
  18. package/lib/types/language-panel.d.ts +12 -0
  19. package/lib/types/locales/en.d.ts +498 -0
  20. package/lib/types/locales/zh.d.ts +9 -0
  21. package/lib/types/mentions.d.ts +7 -3
  22. package/lib/types/models.d.ts +14 -0
  23. package/lib/types/panel-accent.d.ts +28 -0
  24. package/lib/types/rainbow.d.ts +69 -0
  25. package/lib/types/render/animations.d.ts +42 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +95 -4
  30. package/lib/types/render/status.d.ts +12 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +42 -1
  34. package/lib/types/session-switch.d.ts +8 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +8 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +27 -6
  40. package/lib/types/update.d.ts +10 -1
  41. package/lib/types/version.d.ts +6 -3
  42. package/package.json +26 -7
  43. package/src/app.ts +1426 -627
  44. package/src/approval.ts +166 -166
  45. package/src/attachments.ts +65 -19
  46. package/src/authorization-panel.ts +24 -18
  47. package/src/editor-keys.ts +371 -371
  48. package/src/fork.ts +11 -7
  49. package/src/git-workflow.ts +229 -3
  50. package/src/history.ts +14 -0
  51. package/src/i18n.ts +68 -0
  52. package/src/index.ts +503 -128
  53. package/src/input-split.ts +27 -7
  54. package/src/kernel-panels.ts +528 -113
  55. package/src/keyboard.ts +5 -4
  56. package/src/language-panel.ts +53 -0
  57. package/src/locales/en.ts +538 -0
  58. package/src/locales/zh.ts +537 -0
  59. package/src/mentions.ts +8 -4
  60. package/src/models.ts +264 -212
  61. package/src/panel-accent.ts +41 -0
  62. package/src/presets.ts +1 -1
  63. package/src/provider-settings.ts +1 -1
  64. package/src/rainbow.ts +218 -0
  65. package/src/render/animations.ts +104 -6
  66. package/src/render/editor.ts +20 -20
  67. package/src/render/export.ts +116 -95
  68. package/src/render/inspector.ts +42 -0
  69. package/src/render/lines.ts +628 -415
  70. package/src/render/markdown.ts +15 -3
  71. package/src/render/projection.ts +429 -19
  72. package/src/render/status.ts +119 -62
  73. package/src/render/text.ts +15 -0
  74. package/src/render/tool-preview.ts +77 -77
  75. package/src/render/usage.ts +430 -0
  76. package/src/render/width.ts +2 -2
  77. package/src/session-directory.ts +90 -9
  78. package/src/session-query.ts +8 -4
  79. package/src/session-switch.ts +14 -0
  80. package/src/startup.ts +3 -3
  81. package/src/store.ts +19 -1
  82. package/src/subagents.ts +229 -229
  83. package/src/terminal-title.ts +22 -5
  84. package/src/theme-panel.ts +17 -21
  85. package/src/theme.ts +281 -33
  86. package/src/update-panel.ts +148 -31
  87. package/src/update.ts +19 -3
  88. package/src/version.ts +63 -20
  89. package/src/whale-glyph.ts +23 -23
package/src/startup.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * whose project directory matches the current working directory.
15
15
  * - `--session <id>` — create a new session under an explicit identity (the
16
16
  * id must not exist yet).
17
- * - `--theme <dark|light|auto>` — the color palette; auto follows the
17
+ * - `--theme <dark|light|prismatic|rainbow|auto>` — the color palette; auto follows the
18
18
  * terminal (dark fallback until OSC-11 detection lands).
19
19
  * - no flags — a fresh session with a minted id.
20
20
  *
@@ -69,7 +69,7 @@ export function resolveTuiStartup(options: TuiStartupOptions): TuiStartup {
69
69
  throw new Error('--mode applies only to a new session; it cannot be combined with --resume or --continue')
70
70
  }
71
71
  if (options.theme !== undefined && !THEME_NAMES.includes(options.theme)) {
72
- throw new Error('--theme must be dark, light, or auto')
72
+ throw new Error(`--theme must be one of: ${THEME_NAMES.join(', ')}`)
73
73
  }
74
74
  const input = {
75
75
  ...(options.theme === undefined ? {} : { theme: options.theme }),
@@ -99,7 +99,7 @@ function tuiCommand(): Command {
99
99
  .option('-c, --continue', 'resume the most recent persisted session for this working directory')
100
100
  .option('--session <id>', 'create a new session under this explicit id')
101
101
  .option('--mode <preset>', 'agent preset for a newly created session')
102
- .option('--theme <name>', 'color theme: dark (default), light, or auto')
102
+ .option('--theme <name>', 'color theme: dark (default), light, prismatic, rainbow, or auto')
103
103
  .option('-i, --image <path>', 'attach an image to the initial prompt (repeatable)', (path, paths: string[]) => [...paths, path], [])
104
104
  .argument('[prompt...]', 'initial prompt; sends immediately after startup')
105
105
  .addHelpText('after', `
package/src/store.ts CHANGED
@@ -139,8 +139,26 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
139
139
  }
140
140
  },
141
141
  reset(): void {
142
+ // /clear wipes settled history but must not drop the live attempt map:
143
+ // chunk frames after reset still name the same attemptId, and clearing
144
+ // the map froze the stream until settlement dumped the whole reply.
145
+ const live = {
146
+ streaming: acc.streaming,
147
+ streamingReasoning: acc.streamingReasoning,
148
+ busy: acc.busy,
149
+ busySince: acc.busySince,
150
+ model: acc.model,
151
+ firstChunkAt: acc.firstChunkAt,
152
+ stepStart: acc.stepStart,
153
+ }
142
154
  acc = createReplayAccumulator()
143
- attemptKeys.clear()
155
+ acc.streaming = live.streaming
156
+ acc.streamingReasoning = live.streamingReasoning
157
+ acc.busy = live.busy
158
+ acc.busySince = live.busySince
159
+ acc.model = live.model
160
+ acc.firstChunkAt = live.firstChunkAt
161
+ acc.stepStart = live.stepStart
144
162
  dirty = true
145
163
  notify()
146
164
  },
package/src/subagents.ts CHANGED
@@ -1,229 +1,229 @@
1
- /**
2
- * Live subagent activity feed: a bounded, display-only projection of CHILD
3
- * session events. The transcript store folds only the root session (the
4
- * durable truth this TUI renders); subagent conversations are their own
5
- * sessions, and before this module their events were dropped entirely —
6
- * a running subagent was invisible until its parent tool call settled.
7
- *
8
- * This is NOT a second transcript: each child folds to ONE row (label,
9
- * running state, bounded last-activity text), capped at
10
- * {@link MAX_SUBAGENT_ROWS}. The cap is a display budget, not a fan-out
11
- * limit: a new running child evicts the OLDEST settled row when one
12
- * exists, and while every row is busy the newcomer waits off-screen — but
13
- * the observed-session total (getTotalSeen) keeps counting, so status
14
- * totals never under-report the fan-out. Rows are advisory display
15
- * state, rebuilt from live events; nothing here persists or replays.
16
- * Notification is coalesced
17
- * by the same ~16ms frame throttle as the transcript store (per-burst
18
- * microtask notify chained SyncLane rerenders past React's nested update
19
- * limit; a bare macrotask merge repaints a whole turn's bursts at once).
20
- *
21
- * @module @deepseek-ai/dsh-code/subagents
22
- */
23
-
24
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
25
- // Type-only import merges the subagent package's SessionEventMap variant
26
- // ('subagent/catalog') into the union this fold switches on.
27
- import type {} from '@deepseek-ai/dsh-subagent'
28
-
29
- /** Hard row cap: overflow evicts the oldest settled row; a fully busy feed waits. */
30
- export const MAX_SUBAGENT_ROWS = 8
31
-
32
- /** Render frame budget: the notification cadence's upper bound. */
33
- const NOTIFY_FRAME_MS = 16
34
-
35
- /** Bounded last-activity text (plain characters, display-sliced later). */
36
- const MAX_ACTIVITY_CHARS = 80
37
-
38
- /** One live subagent row in the feed. */
39
- export interface SubagentRow {
40
- /** Child session id. */
41
- readonly id: string
42
- /** Display label (session title when observed, else a short id form). */
43
- readonly label: string
44
- /** Coarse lifecycle state folded from the child's events. */
45
- readonly state: 'running' | 'idle' | 'done'
46
- /** Bounded last-activity text for the status line. */
47
- readonly activity: string
48
- /** Last fold time (ms, event clock) — newest-first ordering key. */
49
- readonly updatedAt: number
50
- }
51
-
52
- /** The read-only snapshot surface the renderer subscribes to. */
53
- export interface SubagentFeedView {
54
- /** Subscribe to feed changes; returns the unsubscribe function. */
55
- subscribe(listener: () => void): () => void
56
- /** Read the current rows (identity-stable between changes). */
57
- getSnapshot(): readonly SubagentRow[]
58
- /**
59
- * Distinct child sessions observed since the last reset. The row cap is
60
- * a display budget, not a count of the fan-out; totals surface this.
61
- */
62
- getTotalSeen(): number
63
- }
64
-
65
- /** Single-line bounded preview of an assembled message's text content. */
66
- function messagePreview(content: unknown): string {
67
- if (!Array.isArray(content)) return 'replied'
68
- const texts: string[] = []
69
- for (const block of content) {
70
- if (texts.join(' ').length >= MAX_ACTIVITY_CHARS) break
71
- if (typeof block === 'object' && block !== null) {
72
- const { type, text } = block as Record<string, unknown>
73
- if (type === 'text' && typeof text === 'string' && text !== '') texts.push(text)
74
- }
75
- }
76
- const joined = texts.join(' ').replace(/\s+/gu, ' ').trim()
77
- return joined === '' ? 'replied' : bound(joined)
78
- }
79
-
80
- /** Bound one activity string to the display budget. */
81
- function bound(text: string): string {
82
- const flat = text.replace(/\s+/gu, ' ').trim()
83
- return flat.length > MAX_ACTIVITY_CHARS ? `${flat.slice(0, MAX_ACTIVITY_CHARS - 1)}…` : flat
84
- }
85
-
86
- /**
87
- * Fold one child-session event into its feed row (pure).
88
- * Unknown event kinds leave the row untouched.
89
- * @param previous - the row's current state, when any.
90
- * @param sessionId - the child session id.
91
- * @param event - the child session event.
92
- * @returns the next row state.
93
- */
94
- export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: string, event: SessionEvent): SubagentRow {
95
- const base: SubagentRow = previous ?? {
96
- id: sessionId,
97
- label: `agent ${sessionId.slice(-6)}`,
98
- state: 'running',
99
- activity: 'starting…',
100
- updatedAt: event.time,
101
- }
102
- const data = event.data as Record<string, unknown>
103
- switch (event.type) {
104
- case 'session/title': {
105
- const title = data['title']
106
- const text = typeof title === 'string' && title.trim() !== '' ? title : undefined
107
- return text === undefined || text === base.label ? base : { ...base, label: bound(text), updatedAt: event.time }
108
- }
109
- case 'request/header':
110
- return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
111
- case 'user/message':
112
- return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
113
- case 'assistant/attempt':
114
- // Durable logs are settlement-only since session-log v2; an attempt
115
- // landing without a surface message means the model is retrying or
116
- // recovered from a stream error, so the child stays running.
117
- return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
118
- case 'subagent/catalog': {
119
- // Parent-owned durable discovery fact (0.1.5): the catalog names the
120
- // child's mode (one-shot vs continuable) and its authored label — the
121
- // most semantic label the row can carry. It is a discovery fact, not a
122
- // lifecycle signal: a fresh row starts idle, but a late delivery never
123
- // regresses a row that already ran or finished. An unchanged fact keeps
124
- // the row's identity (the no-op discipline of the default branch), so
125
- // repeated deliveries never churn the snapshot array.
126
- const mode = event.data.mode === 'continuable' ? 'continuable' : 'one-shot'
127
- const label = event.data.label !== undefined && event.data.label.trim() !== '' ? bound(event.data.label) : undefined
128
- const nextLabel = label === undefined ? base.label : label
129
- const activity = label === undefined ? `catalog · ${mode}` : `catalog · ${mode} · ${label}`
130
- const state = previous === undefined ? 'idle' : base.state
131
- if (nextLabel === base.label && state === base.state && activity === base.activity) return base
132
- return { ...base, state, label: nextLabel, activity, updatedAt: event.time }
133
- }
134
- case 'assistant/message':
135
- return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
136
- case 'tool/call': {
137
- const name = typeof data['name'] === 'string' ? data['name'] : 'tool'
138
- return { ...base, state: 'running', activity: `tool ${name}`, updatedAt: event.time }
139
- }
140
- case 'tool/result':
141
- return { ...base, state: 'running', activity: 'tool done', updatedAt: event.time }
142
- case 'turn/start':
143
- return { ...base, state: 'running', activity: base.activity === 'starting…' ? 'working…' : base.activity, updatedAt: event.time }
144
- case 'turn/end':
145
- return { ...base, state: 'done', activity: 'finished', updatedAt: event.time }
146
- default:
147
- // Unknown kinds leave the row untouched (identity-stable: a no-op
148
- // fold must not churn the snapshot array).
149
- return base
150
- }
151
- }
152
-
153
- /**
154
- * Create one subagent feed. `apply` folds a child event (the caller gates
155
- * which sessions are children); `reset` clears on a session switch. Row
156
- * order is first-seen; the snapshot array is frozen and only replaced when
157
- * a row actually changed.
158
- * @returns the mutable feed handle plus its `SubagentFeedView`.
159
- */
160
- export function createSubagentFeed(): SubagentFeedView & {
161
- apply(sessionId: string, event: SessionEvent): void
162
- reset(): void
163
- } {
164
- let rows: readonly SubagentRow[] = Object.freeze([])
165
- const seen = new Set<string>()
166
- const listeners = new Set<() => void>()
167
- let scheduled = false
168
- let lastNotifyAt = 0
169
- const notify = (): void => {
170
- if (scheduled) return
171
- scheduled = true
172
- const wait = NOTIFY_FRAME_MS - (Date.now() - lastNotifyAt)
173
- const dispatch = (): void => {
174
- scheduled = false
175
- lastNotifyAt = Date.now()
176
- for (const listener of listeners) listener()
177
- }
178
- if (wait <= 0) setImmediate(dispatch)
179
- else setTimeout(dispatch, wait)
180
- }
181
- return {
182
- apply(sessionId: string, event: SessionEvent): void {
183
- const index = rows.findIndex(row => row.id === sessionId)
184
- const previous = index === -1 ? undefined : rows[index]
185
- const next = foldSubagentRow(previous, sessionId, event)
186
- if (index !== -1) {
187
- if (next === previous) return
188
- rows = Object.freeze(rows.map((row, at) => at === index ? next : row))
189
- notify()
190
- return
191
- }
192
- // A child this feed has not shown yet: the honest total grows even
193
- // when every row is busy; admission then prefers evicting the OLDEST
194
- // settled row (idle or done — both are non-running) so a new running
195
- // agent never waits on one that already settled.
196
- const counted = !seen.has(sessionId)
197
- if (counted) seen.add(sessionId)
198
- if (rows.length >= MAX_SUBAGENT_ROWS) {
199
- const evict = rows.findIndex(row => row.state !== 'running')
200
- if (evict === -1) {
201
- if (counted) notify()
202
- return
203
- }
204
- rows = Object.freeze([...rows.slice(0, evict), next, ...rows.slice(evict + 1)])
205
- } else {
206
- rows = Object.freeze([...rows, next])
207
- }
208
- notify()
209
- },
210
- reset(): void {
211
- seen.clear()
212
- if (rows.length === 0) return
213
- rows = Object.freeze([])
214
- notify()
215
- },
216
- subscribe(listener: () => void): () => void {
217
- listeners.add(listener)
218
- return () => {
219
- listeners.delete(listener)
220
- }
221
- },
222
- getSnapshot(): readonly SubagentRow[] {
223
- return rows
224
- },
225
- getTotalSeen(): number {
226
- return seen.size
227
- },
228
- }
229
- }
1
+ /**
2
+ * Live subagent activity feed: a bounded, display-only projection of CHILD
3
+ * session events. The transcript store folds only the root session (the
4
+ * durable truth this TUI renders); subagent conversations are their own
5
+ * sessions, and before this module their events were dropped entirely —
6
+ * a running subagent was invisible until its parent tool call settled.
7
+ *
8
+ * This is NOT a second transcript: each child folds to ONE row (label,
9
+ * running state, bounded last-activity text), capped at
10
+ * {@link MAX_SUBAGENT_ROWS}. The cap is a display budget, not a fan-out
11
+ * limit: a new running child evicts the OLDEST settled row when one
12
+ * exists, and while every row is busy the newcomer waits off-screen — but
13
+ * the observed-session total (getTotalSeen) keeps counting, so status
14
+ * totals never under-report the fan-out. Rows are advisory display
15
+ * state, rebuilt from live events; nothing here persists or replays.
16
+ * Notification is coalesced
17
+ * by the same ~16ms frame throttle as the transcript store (per-burst
18
+ * microtask notify chained SyncLane rerenders past React's nested update
19
+ * limit; a bare macrotask merge repaints a whole turn's bursts at once).
20
+ *
21
+ * @module @deepseek-ai/dsh-code/subagents
22
+ */
23
+
24
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
25
+ // Type-only import merges the subagent package's SessionEventMap variant
26
+ // ('subagent/catalog') into the union this fold switches on.
27
+ import type {} from '@deepseek-ai/dsh-subagent'
28
+
29
+ /** Hard row cap: overflow evicts the oldest settled row; a fully busy feed waits. */
30
+ export const MAX_SUBAGENT_ROWS = 8
31
+
32
+ /** Render frame budget: the notification cadence's upper bound. */
33
+ const NOTIFY_FRAME_MS = 16
34
+
35
+ /** Bounded last-activity text (plain characters, display-sliced later). */
36
+ const MAX_ACTIVITY_CHARS = 80
37
+
38
+ /** One live subagent row in the feed. */
39
+ export interface SubagentRow {
40
+ /** Child session id. */
41
+ readonly id: string
42
+ /** Display label (session title when observed, else a short id form). */
43
+ readonly label: string
44
+ /** Coarse lifecycle state folded from the child's events. */
45
+ readonly state: 'running' | 'idle' | 'done'
46
+ /** Bounded last-activity text for the status line. */
47
+ readonly activity: string
48
+ /** Last fold time (ms, event clock) — newest-first ordering key. */
49
+ readonly updatedAt: number
50
+ }
51
+
52
+ /** The read-only snapshot surface the renderer subscribes to. */
53
+ export interface SubagentFeedView {
54
+ /** Subscribe to feed changes; returns the unsubscribe function. */
55
+ subscribe(listener: () => void): () => void
56
+ /** Read the current rows (identity-stable between changes). */
57
+ getSnapshot(): readonly SubagentRow[]
58
+ /**
59
+ * Distinct child sessions observed since the last reset. The row cap is
60
+ * a display budget, not a count of the fan-out; totals surface this.
61
+ */
62
+ getTotalSeen(): number
63
+ }
64
+
65
+ /** Single-line bounded preview of an assembled message's text content. */
66
+ function messagePreview(content: unknown): string {
67
+ if (!Array.isArray(content)) return 'replied'
68
+ const texts: string[] = []
69
+ for (const block of content) {
70
+ if (texts.join(' ').length >= MAX_ACTIVITY_CHARS) break
71
+ if (typeof block === 'object' && block !== null) {
72
+ const { type, text } = block as Record<string, unknown>
73
+ if (type === 'text' && typeof text === 'string' && text !== '') texts.push(text)
74
+ }
75
+ }
76
+ const joined = texts.join(' ').replace(/\s+/gu, ' ').trim()
77
+ return joined === '' ? 'replied' : bound(joined)
78
+ }
79
+
80
+ /** Bound one activity string to the display budget. */
81
+ function bound(text: string): string {
82
+ const flat = text.replace(/\s+/gu, ' ').trim()
83
+ return flat.length > MAX_ACTIVITY_CHARS ? `${flat.slice(0, MAX_ACTIVITY_CHARS - 1)}…` : flat
84
+ }
85
+
86
+ /**
87
+ * Fold one child-session event into its feed row (pure).
88
+ * Unknown event kinds leave the row untouched.
89
+ * @param previous - the row's current state, when any.
90
+ * @param sessionId - the child session id.
91
+ * @param event - the child session event.
92
+ * @returns the next row state.
93
+ */
94
+ export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: string, event: SessionEvent): SubagentRow {
95
+ const base: SubagentRow = previous ?? {
96
+ id: sessionId,
97
+ label: `agent ${sessionId.slice(-6)}`,
98
+ state: 'running',
99
+ activity: 'starting…',
100
+ updatedAt: event.time,
101
+ }
102
+ const data = event.data as Record<string, unknown>
103
+ switch (event.type) {
104
+ case 'session/title': {
105
+ const title = data['title']
106
+ const text = typeof title === 'string' && title.trim() !== '' ? title : undefined
107
+ return text === undefined || text === base.label ? base : { ...base, label: bound(text), updatedAt: event.time }
108
+ }
109
+ case 'request/header':
110
+ return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
111
+ case 'user/message':
112
+ return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
113
+ case 'assistant/attempt':
114
+ // Durable logs are settlement-only since session-log v2; an attempt
115
+ // landing without a surface message means the model is retrying or
116
+ // recovered from a stream error, so the child stays running.
117
+ return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
118
+ case 'subagent/catalog': {
119
+ // Parent-owned durable discovery fact (0.1.5): the catalog names the
120
+ // child's mode (one-shot vs continuable) and its authored label — the
121
+ // most semantic label the row can carry. It is a discovery fact, not a
122
+ // lifecycle signal: a fresh row starts idle, but a late delivery never
123
+ // regresses a row that already ran or finished. An unchanged fact keeps
124
+ // the row's identity (the no-op discipline of the default branch), so
125
+ // repeated deliveries never churn the snapshot array.
126
+ const mode = event.data.mode === 'continuable' ? 'continuable' : 'one-shot'
127
+ const label = event.data.label !== undefined && event.data.label.trim() !== '' ? bound(event.data.label) : undefined
128
+ const nextLabel = label === undefined ? base.label : label
129
+ const activity = label === undefined ? `catalog · ${mode}` : `catalog · ${mode} · ${label}`
130
+ const state = previous === undefined ? 'idle' : base.state
131
+ if (nextLabel === base.label && state === base.state && activity === base.activity) return base
132
+ return { ...base, state, label: nextLabel, activity, updatedAt: event.time }
133
+ }
134
+ case 'assistant/message':
135
+ return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
136
+ case 'tool/call': {
137
+ const name = typeof data['name'] === 'string' ? data['name'] : 'tool'
138
+ return { ...base, state: 'running', activity: `tool ${name}`, updatedAt: event.time }
139
+ }
140
+ case 'tool/result':
141
+ return { ...base, state: 'running', activity: 'tool done', updatedAt: event.time }
142
+ case 'turn/start':
143
+ return { ...base, state: 'running', activity: base.activity === 'starting…' ? 'working…' : base.activity, updatedAt: event.time }
144
+ case 'turn/end':
145
+ return { ...base, state: 'done', activity: 'finished', updatedAt: event.time }
146
+ default:
147
+ // Unknown kinds leave the row untouched (identity-stable: a no-op
148
+ // fold must not churn the snapshot array).
149
+ return base
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Create one subagent feed. `apply` folds a child event (the caller gates
155
+ * which sessions are children); `reset` clears on a session switch. Row
156
+ * order is first-seen; the snapshot array is frozen and only replaced when
157
+ * a row actually changed.
158
+ * @returns the mutable feed handle plus its `SubagentFeedView`.
159
+ */
160
+ export function createSubagentFeed(): SubagentFeedView & {
161
+ apply(sessionId: string, event: SessionEvent): void
162
+ reset(): void
163
+ } {
164
+ let rows: readonly SubagentRow[] = Object.freeze([])
165
+ const seen = new Set<string>()
166
+ const listeners = new Set<() => void>()
167
+ let scheduled = false
168
+ let lastNotifyAt = 0
169
+ const notify = (): void => {
170
+ if (scheduled) return
171
+ scheduled = true
172
+ const wait = NOTIFY_FRAME_MS - (Date.now() - lastNotifyAt)
173
+ const dispatch = (): void => {
174
+ scheduled = false
175
+ lastNotifyAt = Date.now()
176
+ for (const listener of listeners) listener()
177
+ }
178
+ if (wait <= 0) setImmediate(dispatch)
179
+ else setTimeout(dispatch, wait)
180
+ }
181
+ return {
182
+ apply(sessionId: string, event: SessionEvent): void {
183
+ const index = rows.findIndex(row => row.id === sessionId)
184
+ const previous = index === -1 ? undefined : rows[index]
185
+ const next = foldSubagentRow(previous, sessionId, event)
186
+ if (index !== -1) {
187
+ if (next === previous) return
188
+ rows = Object.freeze(rows.map((row, at) => at === index ? next : row))
189
+ notify()
190
+ return
191
+ }
192
+ // A child this feed has not shown yet: the honest total grows even
193
+ // when every row is busy; admission then prefers evicting the OLDEST
194
+ // settled row (idle or done — both are non-running) so a new running
195
+ // agent never waits on one that already settled.
196
+ const counted = !seen.has(sessionId)
197
+ if (counted) seen.add(sessionId)
198
+ if (rows.length >= MAX_SUBAGENT_ROWS) {
199
+ const evict = rows.findIndex(row => row.state !== 'running')
200
+ if (evict === -1) {
201
+ if (counted) notify()
202
+ return
203
+ }
204
+ rows = Object.freeze([...rows.slice(0, evict), next, ...rows.slice(evict + 1)])
205
+ } else {
206
+ rows = Object.freeze([...rows, next])
207
+ }
208
+ notify()
209
+ },
210
+ reset(): void {
211
+ seen.clear()
212
+ if (rows.length === 0) return
213
+ rows = Object.freeze([])
214
+ notify()
215
+ },
216
+ subscribe(listener: () => void): () => void {
217
+ listeners.add(listener)
218
+ return () => {
219
+ listeners.delete(listener)
220
+ }
221
+ },
222
+ getSnapshot(): readonly SubagentRow[] {
223
+ return rows
224
+ },
225
+ getTotalSeen(): number {
226
+ return seen.size
227
+ },
228
+ }
229
+ }
@@ -74,6 +74,25 @@ export function clearTerminalTitleSequence(): string {
74
74
  return '\x1b]0;\x07'
75
75
  }
76
76
 
77
+ /**
78
+ * Resolve the VS Code stable user-settings file for one platform and
79
+ * environment: `%APPDATA%` on Windows, the bundle data folder on macOS (NOT
80
+ * XDG `~/.config` — VS Code never reads that path there, so writing it
81
+ * silently no-ops and the tab kept showing the process name "node"), and
82
+ * XDG config on Linux.
83
+ */
84
+ export function vscodeSettingsPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | undefined {
85
+ const windows = platform === 'win32'
86
+ const darwin = platform === 'darwin'
87
+ const base = windows ? env['APPDATA'] : env['HOME']
88
+ if (base === undefined || base === '') return undefined
89
+ return windows
90
+ ? join(base, 'Code', 'User', 'settings.json')
91
+ : darwin
92
+ ? join(base, 'Library', 'Application Support', 'Code', 'User', 'settings.json')
93
+ : join(base, '.config', 'Code', 'User', 'settings.json')
94
+ }
95
+
77
96
  /** Outcome of the VS Code settings alignment. */
78
97
  export interface VsCodeTitleSettingResult {
79
98
  wrote: boolean
@@ -98,11 +117,9 @@ export function ensureVsCodeTabTitleSetting(options: {
98
117
  if ((options.isTTY ?? process.stdout.isTTY) !== true) return { wrote: false, reason: 'not-vscode' }
99
118
  let file = options.settingsFile
100
119
  if (file === undefined) {
101
- const base = process.platform === 'win32' ? env['APPDATA'] : env['HOME']
102
- if (base === undefined || base === '') return { wrote: false, reason: 'error' }
103
- file = process.platform === 'win32'
104
- ? join(base, 'Code', 'User', 'settings.json')
105
- : join(base, '.config', 'Code', 'User', 'settings.json')
120
+ const resolved = vscodeSettingsPath(process.platform, env)
121
+ if (resolved === undefined) return { wrote: false, reason: 'error' }
122
+ file = resolved
106
123
  }
107
124
  try {
108
125
  if (!existsSync(file)) {
@@ -11,14 +11,9 @@ import { createElement, useState, type ReactElement } from 'react'
11
11
  import { Box, Text, useInput, useStdout } from 'ink'
12
12
  import { clampScroll, panelViewport } from './render/inspector.ts'
13
13
  import { truncateColumns } from './render/text.ts'
14
- import { getPalette, inkColor, type ThemeName } from './theme.ts'
15
-
16
- /** The three theme rows in canonical order (the /theme selection surface). */
17
- const THEME_ROWS: readonly { id: ThemeName; label: string; description: string }[] = [
18
- { id: 'dark', label: 'dark', description: 'DeepSeek dark palette (default)' },
19
- { id: 'light', label: 'light', description: 'light palette for bright terminals' },
20
- { id: 'auto', label: 'auto', description: 'follow the terminal; dark until detection lands' },
21
- ]
14
+ import { panelAccent } from './panel-accent.ts'
15
+ import { getPalette, inkColor, THEMES, type ThemeName } from './theme.ts'
16
+ import { t } from './i18n.ts'
22
17
 
23
18
  /**
24
19
  * The /theme list: one row per theme, the current one marked with ●, the
@@ -30,37 +25,38 @@ export function ThemePanel({ current, select, close }: {
30
25
  /** Theme name in force (the requested name; 'auto' included). */
31
26
  current: ThemeName
32
27
  /** Accept one theme name: applied immediately and persisted by the runner. */
33
- select(name: ThemeName): void
28
+ select: (name: ThemeName) => void
34
29
  /** Close without changing the theme. */
35
- close(): void
30
+ close: () => void
36
31
  }): ReactElement {
37
32
  const [cursor, setCursor] = useState(() => {
38
- const index = THEME_ROWS.findIndex(theme => theme.id === current)
33
+ const index = THEMES.findIndex(theme => theme.id === current)
39
34
  return index < 0 ? 0 : index
40
35
  })
41
36
  const stdout = useStdout().stdout
42
37
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
43
38
  useInput((input, key) => {
44
39
  if (key.escape || input === 'q') return close()
45
- if (key.upArrow) return setCursor(value => (value + THEME_ROWS.length - 1) % THEME_ROWS.length)
46
- if (key.downArrow) return setCursor(value => (value + 1) % THEME_ROWS.length)
47
- if (key.return) return select(THEME_ROWS[cursor]!.id)
40
+ if (key.upArrow) return setCursor(value => (value + THEMES.length - 1) % THEMES.length)
41
+ if (key.downArrow) return setCursor(value => (value + 1) % THEMES.length)
42
+ if (key.return) return select(THEMES[cursor].id)
48
43
  })
49
44
  if (viewport.maxHeight === 0 || viewport.compact) {
50
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/theme · esc close', viewport.contentColumns))
45
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('theme.compact'), viewport.contentColumns))
51
46
  }
52
47
  // The theme rows share the panel's body budget like every other panel: an
53
48
  // unsliced three-row list reached terminal-height equality on short
54
49
  // terminals, where Ink rewrites the whole Static region every frame.
55
50
  // Reveal-cursor slicing keeps the focused row visible instead.
56
51
  const rowBudget = Math.max(1, viewport.bodyRows)
57
- const first = clampScroll(cursor, THEME_ROWS.length, rowBudget)
58
- const visibleThemes = THEME_ROWS.slice(first, first + rowBudget)
59
- const hiddenThemes = THEME_ROWS.length - visibleThemes.length
52
+ const first = clampScroll(cursor, THEMES.length, rowBudget)
53
+ const visibleThemes = THEMES.slice(first, first + rowBudget)
54
+ const hiddenThemes = THEMES.length - visibleThemes.length
55
+ const accent = panelAccent('theme', getPalette().dim, getPalette().brandBright)
60
56
  return createElement(
61
57
  Box,
62
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
63
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/theme — color palette', viewport.contentColumns)),
58
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
59
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('theme.title'), viewport.contentColumns)),
64
60
  ...visibleThemes.map((theme, index) => {
65
61
  const selected = first + index === cursor
66
62
  const active = theme.id === current
@@ -71,7 +67,7 @@ export function ThemePanel({ current, select, close }: {
71
67
  color: selected ? inkColor(getPalette().brandBright) : undefined,
72
68
  wrap: 'truncate-end',
73
69
  },
74
- truncateColumns(`${selected ? '› ' : ' '}${active ? '● ' : '○ '}${theme.label}${active ? ' · current' : ''} · ${theme.description}`, viewport.contentColumns),
70
+ truncateColumns(`${selected ? '› ' : ' '}${active ? '● ' : '○ '}${t(`theme.${theme.id}.label`)}${active ? ` · ${t('theme.current')}` : ''} · ${t(`theme.${theme.id}.description`)}`, viewport.contentColumns),
75
71
  )
76
72
  }),
77
73
  createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`↑↓ choose · enter apply · esc/q close${hiddenThemes > 0 ? ` · +${hiddenThemes} more` : ''}`, viewport.contentColumns)),