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.
- package/README.en.md +123 -26
- package/README.md +124 -27
- package/bin/deepseek.mjs +283 -35
- package/cordis.patch.yml +97 -0
- package/lib/index.mjs +5008 -881
- package/lib/session-query.mjs +150 -0
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
- package/lib/types/app.d.ts +106 -62
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +100 -1
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +107 -29
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +450 -0
- package/lib/types/locales/zh.d.ts +9 -0
- package/lib/types/mentions.d.ts +7 -3
- package/lib/types/models.d.ts +14 -0
- package/lib/types/panel-accent.d.ts +28 -0
- package/lib/types/rainbow.d.ts +69 -0
- package/lib/types/render/animations.d.ts +42 -0
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/inspector.d.ts +26 -0
- package/lib/types/render/lines.d.ts +21 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +130 -4
- package/lib/types/render/status.d.ts +9 -9
- package/lib/types/render/text.d.ts +6 -0
- package/lib/types/render/usage.d.ts +113 -0
- package/lib/types/session-directory.d.ts +17 -0
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +66 -0
- package/lib/types/theme-panel.d.ts +2 -2
- package/lib/types/theme.d.ts +271 -52
- package/lib/types/update-panel.d.ts +49 -0
- package/lib/types/update.d.ts +75 -0
- package/lib/types/version.d.ts +4 -3
- package/package.json +246 -90
- package/src/app.ts +1369 -509
- package/src/approval.ts +166 -166
- package/src/authorization-panel.ts +19 -16
- package/src/editor-keys.ts +371 -371
- package/src/git-workflow.ts +229 -3
- package/src/i18n.ts +68 -0
- package/src/index.ts +534 -80
- package/src/input-split.ts +3 -3
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +554 -86
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +489 -0
- package/src/locales/zh.ts +488 -0
- package/src/mentions.ts +8 -4
- package/src/models.ts +264 -212
- package/src/panel-accent.ts +41 -0
- package/src/presets.ts +1 -1
- package/src/provider-settings.ts +1 -1
- package/src/rainbow.ts +208 -0
- package/src/render/animations.ts +104 -6
- package/src/render/editor.ts +25 -24
- package/src/render/export.ts +116 -95
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/inspector.ts +42 -0
- package/src/render/lines.ts +628 -415
- package/src/render/markdown.ts +15 -3
- package/src/render/projection.ts +572 -21
- package/src/render/status.ts +59 -39
- package/src/render/text.ts +14 -0
- package/src/render/tool-preview.ts +77 -77
- package/src/render/usage.ts +430 -0
- package/src/render/width.ts +2 -2
- package/src/session-directory.ts +8 -6
- package/src/session-query.ts +239 -0
- package/src/startup.ts +3 -3
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +190 -0
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +256 -0
- package/src/update.ts +126 -0
- package/src/version.ts +58 -20
- 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
|
+
}
|