dsh-code 1.0.6 → 1.2.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 (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -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 +130 -4
  30. package/lib/types/render/status.d.ts +9 -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 +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -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 +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
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
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Terminal tab/window title management for the TUI.
3
+ *
4
+ * Terminals label their tab from the window title, which an application sets
5
+ * with an OSC 0 sequence; without one the tab shows the process name ("node").
6
+ * The title text is untrusted display content (session names arrive through
7
+ * events and user input), so it is sanitized before emission: control
8
+ * characters and bidi/invisible formatting codepoints are stripped, whitespace
9
+ * runs collapse to single spaces, and the result is bounded. Clearing writes
10
+ * an empty OSC payload and the terminal falls back to its own default; the
11
+ * previously set title is not portable to read back and is never restored.
12
+ *
13
+ * @module @deepseek-ai/dsh-code/terminal-title
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { dirname, join } from 'node:path'
18
+ import { useLayoutEffect, useRef } from 'react'
19
+ import { useStdout } from 'ink'
20
+
21
+ /** The host process title at module load, restored on unmount. On Windows
22
+ * this reaches the tab through SetConsoleTitleW (ConPTY reflects it to VS
23
+ * Code and friends without any user setting); on POSIX it becomes the ps
24
+ * name. Node replaces the argv memory, so the original must be saved before
25
+ * the first assignment. */
26
+ const initialProcessTitle = process.title
27
+
28
+ /** Tab label before a session carries a name. */
29
+ export const DEFAULT_TERMINAL_TITLE = 'deepseek'
30
+
31
+ /** Practical upper bound on title length, in visible characters: long enough
32
+ * for session names, short enough for tab bars and window managers. */
33
+ export const MAX_TERMINAL_TITLE_CHARS = 240
34
+
35
+ /** Control characters, DEL/C1 range, and bidi or invisible formatting
36
+ * codepoints that could terminate the OSC sequence or visually reorder the
37
+ * title relative to its underlying text. */
38
+ const DISALLOWED_TITLE_CHARS = /[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/
39
+
40
+ /** Normalize untrusted title text into one bounded display line: disallowed
41
+ * codepoints dropped, whitespace runs collapsed to single spaces, leading and
42
+ * trailing whitespace removed, length bounded. */
43
+ export function sanitizeTerminalTitle(text: string): string {
44
+ const chars: string[] = []
45
+ let pendingSpace = false
46
+ for (const ch of text) {
47
+ if (DISALLOWED_TITLE_CHARS.test(ch)) continue
48
+ if (ch.trim() === '') {
49
+ if (chars.length > 0) pendingSpace = true
50
+ continue
51
+ }
52
+ if (pendingSpace) {
53
+ if (chars.length + 1 >= MAX_TERMINAL_TITLE_CHARS) break
54
+ chars.push(' ')
55
+ pendingSpace = false
56
+ }
57
+ if (chars.length >= MAX_TERMINAL_TITLE_CHARS) break
58
+ chars.push(ch)
59
+ }
60
+ return chars.join('')
61
+ }
62
+
63
+ /** Build one OSC 0 title sequence. An empty sanitized title yields an empty
64
+ * string: emitting nothing is distinct from clearing, which is a separate
65
+ * policy decision made by the caller. */
66
+ export function terminalTitleSequence(text: string): string {
67
+ const title = sanitizeTerminalTitle(text)
68
+ return title === '' ? '' : `\x1b]0;${title}\x07`
69
+ }
70
+
71
+ /** Clear the managed title with an empty OSC payload; the terminal falls back
72
+ * to its own default label. */
73
+ export function clearTerminalTitleSequence(): string {
74
+ return '\x1b]0;\x07'
75
+ }
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
+
96
+ /** Outcome of the VS Code settings alignment. */
97
+ export interface VsCodeTitleSettingResult {
98
+ wrote: boolean
99
+ reason?: 'not-vscode' | 'unparseable' | 'key-present' | 'error'
100
+ }
101
+
102
+ /** VS Code renders an application-set tab title only when
103
+ * "terminal.integrated.tabs.title" maps to the sequence variable; the editor
104
+ * default shows the process name instead ("node" for a Node CLI). Inside a VS
105
+ * Code integrated terminal, align the user settings once: if the key is
106
+ * absent, insert it and keep a one-shot backup of the original file. A value
107
+ * the user already set is never overwritten, an unparseable file is never
108
+ * touched, and every failure degrades to a no-op - the OSC and process-title
109
+ * channels keep working everywhere else. */
110
+ export function ensureVsCodeTabTitleSetting(options: {
111
+ env?: NodeJS.ProcessEnv
112
+ settingsFile?: string
113
+ isTTY?: boolean
114
+ } = {}): VsCodeTitleSettingResult {
115
+ const env = options.env ?? process.env
116
+ if (env['TERM_PROGRAM'] !== 'vscode') return { wrote: false, reason: 'not-vscode' }
117
+ if ((options.isTTY ?? process.stdout.isTTY) !== true) return { wrote: false, reason: 'not-vscode' }
118
+ let file = options.settingsFile
119
+ if (file === undefined) {
120
+ const resolved = vscodeSettingsPath(process.platform, env)
121
+ if (resolved === undefined) return { wrote: false, reason: 'error' }
122
+ file = resolved
123
+ }
124
+ try {
125
+ if (!existsSync(file)) {
126
+ mkdirSync(dirname(file), { recursive: true })
127
+ writeFileSync(file, '{\n "terminal.integrated.tabs.title": "${sequence}"\n}\n', 'utf8')
128
+ return { wrote: true }
129
+ }
130
+ const text = readFileSync(file, 'utf8')
131
+ if (text.includes('"terminal.integrated.tabs.title"')) return { wrote: false, reason: 'key-present' }
132
+ try {
133
+ JSON.parse(text)
134
+ } catch {
135
+ return { wrote: false, reason: 'unparseable' }
136
+ }
137
+ const close = text.lastIndexOf('}')
138
+ if (close < 0) return { wrote: false, reason: 'unparseable' }
139
+ const before = text.slice(0, close)
140
+ const trimmedBefore = before.replace(/[ \t\r\n]+$/, '')
141
+ const tail = before.slice(trimmedBefore.length)
142
+ const needsComma = trimmedBefore.trim() !== '' && !trimmedBefore.trimEnd().endsWith('{')
143
+ const inserted = (needsComma ? ',' : '') + '\n "terminal.integrated.tabs.title": "${sequence}"\n'
144
+ writeFileSync(file + '.dsh-backup', text, 'utf8')
145
+ writeFileSync(file, trimmedBefore + inserted + tail + text.slice(close), 'utf8')
146
+ return { wrote: true }
147
+ } catch {
148
+ return { wrote: false, reason: 'error' }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Keep the terminal tab label on `title` (sanitized; empty titles leave the
154
+ * current label alone). Two delivery channels run in parallel: the OSC 0
155
+ * sequence to stdout, and the host process title. Writes are deduplicated by
156
+ * title, and on unmount the managed title is cleared and the process title
157
+ * restored so the host shell regains its default label.
158
+ */
159
+ export function useTerminalTitle(title: string, options: { clearOnUnmount?: boolean } = {}): void {
160
+ const { clearOnUnmount = true } = options
161
+ const { stdout } = useStdout()
162
+ const writtenRef = useRef<string | undefined>(undefined)
163
+ const processTitleRef = useRef(false)
164
+ // Layout effects: the clear must run synchronously at unmount (passive
165
+ // cleanups are not flushed synchronously when Ink tears the tree down).
166
+ useLayoutEffect(() => {
167
+ if (stdout === undefined) return undefined
168
+ return () => {
169
+ if (processTitleRef.current) {
170
+ processTitleRef.current = false
171
+ process.title = initialProcessTitle
172
+ }
173
+ if (clearOnUnmount && writtenRef.current !== undefined) stdout.write(clearTerminalTitleSequence())
174
+ }
175
+ }, [stdout, clearOnUnmount])
176
+ useLayoutEffect(() => {
177
+ if (stdout === undefined) return
178
+ if (title === writtenRef.current) return
179
+ const sequence = terminalTitleSequence(title)
180
+ if (sequence === '') return
181
+ stdout.write(sequence)
182
+ // The process title is the second delivery channel: on Windows it drives
183
+ // the console title that ConPTY reflects onto the tab without any user
184
+ // setting, where the OSC channel alone only shows once the host maps
185
+ // "terminal.integrated.tabs.title" to the sequence variable.
186
+ process.title = sanitizeTerminalTitle(title)
187
+ processTitleRef.current = true
188
+ writtenRef.current = title
189
+ }, [stdout, title])
190
+ }