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.
- package/README.en.md +70 -24
- package/README.md +71 -25
- package/bin/deepseek.mjs +202 -39
- package/cordis.patch.yml +13 -4
- package/lib/index.mjs +5002 -1061
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
- package/lib/types/app.d.ts +120 -63
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/history.d.ts +10 -0
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +74 -2
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +89 -32
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +498 -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/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 +95 -4
- package/lib/types/render/status.d.ts +12 -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 +42 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +8 -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 +27 -6
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +6 -3
- package/package.json +26 -7
- package/src/app.ts +1426 -627
- package/src/approval.ts +166 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +24 -18
- package/src/editor-keys.ts +371 -371
- package/src/fork.ts +11 -7
- package/src/git-workflow.ts +229 -3
- package/src/history.ts +14 -0
- package/src/i18n.ts +68 -0
- package/src/index.ts +503 -128
- package/src/input-split.ts +27 -7
- package/src/kernel-panels.ts +528 -113
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +538 -0
- package/src/locales/zh.ts +537 -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 +218 -0
- package/src/render/animations.ts +104 -6
- package/src/render/editor.ts +20 -20
- package/src/render/export.ts +116 -95
- 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 +429 -19
- package/src/render/status.ts +119 -62
- package/src/render/text.ts +15 -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 +90 -9
- package/src/session-query.ts +8 -4
- package/src/session-switch.ts +14 -0
- package/src/startup.ts +3 -3
- package/src/store.ts +19 -1
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +22 -5
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +148 -31
- package/src/update.ts +19 -3
- package/src/version.ts +63 -20
- 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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/terminal-title.ts
CHANGED
|
@@ -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
|
|
102
|
-
if (
|
|
103
|
-
file =
|
|
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)) {
|
package/src/theme-panel.ts
CHANGED
|
@@ -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 {
|
|
15
|
-
|
|
16
|
-
|
|
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)
|
|
28
|
+
select: (name: ThemeName) => void
|
|
34
29
|
/** Close without changing the theme. */
|
|
35
|
-
close()
|
|
30
|
+
close: () => void
|
|
36
31
|
}): ReactElement {
|
|
37
32
|
const [cursor, setCursor] = useState(() => {
|
|
38
|
-
const index =
|
|
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 +
|
|
46
|
-
if (key.downArrow) return setCursor(value => (value + 1) %
|
|
47
|
-
if (key.return) return select(
|
|
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('
|
|
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,
|
|
58
|
-
const visibleThemes =
|
|
59
|
-
const hiddenThemes =
|
|
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(
|
|
63
|
-
createElement(Text, { color: inkColor(
|
|
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 ?
|
|
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)),
|