dsh-code 1.0.2 → 1.0.4
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 +21 -13
- package/README.md +285 -271
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2962 -1560
- package/lib/types/app.d.ts +13 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/index.d.ts +28 -0
- package/lib/types/input-split.d.ts +54 -0
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/keyboard.d.ts +8 -0
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +84 -0
- package/lib/types/render/lines.d.ts +25 -0
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +22 -2
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/skills.d.ts +1 -1
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +4514 -3892
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1752 -1523
- package/src/input-split.ts +191 -0
- package/src/internals.ts +26 -8
- package/src/kernel-panels.ts +26 -10
- package/src/keyboard.ts +123 -88
- package/src/mentions.ts +42 -9
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +220 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +415 -356
- package/src/render/markdown.ts +18 -19
- package/src/render/projection.ts +162 -52
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +158 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/skills.ts +19 -6
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
- package/src/theme-panel.ts +79 -72
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Precise terminal-cell width measurement — the single authority every
|
|
3
|
+
* budget, wrap, and truncation path shares. The previous heuristic
|
|
4
|
+
* (`codePoint > 0x2e7f ? 2 : 1)) mis-sized Hangul Jamo (narrow), high
|
|
5
|
+
* non-CJK code points (wide), and emoji: text-default glyphs like ✳ ⚠ ❤
|
|
6
|
+
* counted 2 while terminals draw 1, and VS16 sequences counted 1 while
|
|
7
|
+
* terminals draw 2 — the exact drift class the community dsh-TUI string
|
|
8
|
+
* engine documents (a spinner glyph drifting one column per frame). This
|
|
9
|
+
* module adapts that engine's rules without its Ink-fork renderer: an ASCII
|
|
10
|
+
* fast path, grapheme-cluster iteration via Intl.Segmenter (code-point
|
|
11
|
+
* fallback), a merged East-Asian-Wide/Fullwidth + Emoji_Presentation range
|
|
12
|
+
* table, text-default emoji = 1, VS16 = 2, marks/selectors/ZWJ = 0.
|
|
13
|
+
* @module @deepseek-ai/dsh-code/render/width
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Code-point ranges rendered two cells wide: East Asian Wide/Fullwidth
|
|
18
|
+
* blocks merged with the Emoji_Presentation set (default-wide emoji) and
|
|
19
|
+
* regional indicators (flags). Sorted, inclusive, binary-searched.
|
|
20
|
+
*/
|
|
21
|
+
const WIDE_RANGES: readonly (readonly [number, number])[] = [
|
|
22
|
+
[0x1100, 0x115f],
|
|
23
|
+
[0x231a, 0x231b],
|
|
24
|
+
[0x2329, 0x232a],
|
|
25
|
+
[0x23e9, 0x23ec],
|
|
26
|
+
[0x23f0, 0x23f0],
|
|
27
|
+
[0x23f3, 0x23f3],
|
|
28
|
+
[0x23f8, 0x23fa],
|
|
29
|
+
[0x25fd, 0x25fe],
|
|
30
|
+
[0x2614, 0x2615],
|
|
31
|
+
[0x2648, 0x2653],
|
|
32
|
+
[0x267f, 0x267f],
|
|
33
|
+
[0x2693, 0x2693],
|
|
34
|
+
[0x26a1, 0x26a1],
|
|
35
|
+
[0x26aa, 0x26ab],
|
|
36
|
+
[0x26bd, 0x26be],
|
|
37
|
+
[0x26c4, 0x26c5],
|
|
38
|
+
[0x26ce, 0x26ce],
|
|
39
|
+
[0x26d4, 0x26d4],
|
|
40
|
+
[0x26ea, 0x26ea],
|
|
41
|
+
[0x26f2, 0x26f3],
|
|
42
|
+
[0x26f5, 0x26f5],
|
|
43
|
+
[0x26fa, 0x26fa],
|
|
44
|
+
[0x26fd, 0x26fd],
|
|
45
|
+
[0x2705, 0x2705],
|
|
46
|
+
[0x270a, 0x270b],
|
|
47
|
+
[0x2728, 0x2728],
|
|
48
|
+
[0x274c, 0x274c],
|
|
49
|
+
[0x274e, 0x274e],
|
|
50
|
+
[0x2753, 0x2755],
|
|
51
|
+
[0x2757, 0x2757],
|
|
52
|
+
[0x2795, 0x2797],
|
|
53
|
+
[0x27b0, 0x27b0],
|
|
54
|
+
[0x27bf, 0x27bf],
|
|
55
|
+
[0x2b1b, 0x2b1c],
|
|
56
|
+
[0x2b50, 0x2b50],
|
|
57
|
+
[0x2b55, 0x2b55],
|
|
58
|
+
[0x2e80, 0x303e],
|
|
59
|
+
[0x3041, 0x33ff],
|
|
60
|
+
[0x3400, 0x4dbf],
|
|
61
|
+
[0x4e00, 0xa4cf],
|
|
62
|
+
[0xa960, 0xa97f],
|
|
63
|
+
[0xac00, 0xd7a3],
|
|
64
|
+
[0xf900, 0xfaff],
|
|
65
|
+
[0xfe10, 0xfe19],
|
|
66
|
+
[0xfe30, 0xfe4f],
|
|
67
|
+
[0xff00, 0xff60],
|
|
68
|
+
[0xffe0, 0xffe6],
|
|
69
|
+
[0x16fe0, 0x16fe4],
|
|
70
|
+
[0x17000, 0x18aff],
|
|
71
|
+
[0x1b000, 0x1b2ff],
|
|
72
|
+
[0x1f004, 0x1f004],
|
|
73
|
+
[0x1f0cf, 0x1f0cf],
|
|
74
|
+
[0x1f18e, 0x1f18e],
|
|
75
|
+
[0x1f191, 0x1f19a],
|
|
76
|
+
[0x1f1e6, 0x1f1ff],
|
|
77
|
+
[0x1f200, 0x1f320],
|
|
78
|
+
[0x1f32d, 0x1f335],
|
|
79
|
+
[0x1f337, 0x1f37c],
|
|
80
|
+
[0x1f37e, 0x1f393],
|
|
81
|
+
[0x1f3a0, 0x1f3ca],
|
|
82
|
+
[0x1f3cf, 0x1f3d3],
|
|
83
|
+
[0x1f3e0, 0x1f3f0],
|
|
84
|
+
[0x1f3f4, 0x1f3f4],
|
|
85
|
+
[0x1f3f8, 0x1f43e],
|
|
86
|
+
[0x1f440, 0x1f440],
|
|
87
|
+
[0x1f442, 0x1f4fc],
|
|
88
|
+
[0x1f4ff, 0x1f53d],
|
|
89
|
+
[0x1f54b, 0x1f54e],
|
|
90
|
+
[0x1f550, 0x1f567],
|
|
91
|
+
[0x1f57a, 0x1f57a],
|
|
92
|
+
[0x1f595, 0x1f596],
|
|
93
|
+
[0x1f5a4, 0x1f5a4],
|
|
94
|
+
[0x1f5fb, 0x1f64f],
|
|
95
|
+
[0x1f680, 0x1f6c5],
|
|
96
|
+
[0x1f6cc, 0x1f6cc],
|
|
97
|
+
[0x1f6d0, 0x1f6d2],
|
|
98
|
+
[0x1f6d5, 0x1f6d7],
|
|
99
|
+
[0x1f6eb, 0x1f6ec],
|
|
100
|
+
[0x1f6f4, 0x1f6fc],
|
|
101
|
+
[0x1f7e0, 0x1f7eb],
|
|
102
|
+
[0x1f90c, 0x1f93a],
|
|
103
|
+
[0x1f93c, 0x1f945],
|
|
104
|
+
[0x1f947, 0x1f9ff],
|
|
105
|
+
[0x1fa70, 0x1fa7c],
|
|
106
|
+
[0x1fa80, 0x1fa89],
|
|
107
|
+
[0x1fa8f, 0x1fac6],
|
|
108
|
+
[0x1face, 0x1fadc],
|
|
109
|
+
[0x1fadf, 0x1fae9],
|
|
110
|
+
[0x1faf0, 0x1faf8],
|
|
111
|
+
[0x20000, 0x2fffd],
|
|
112
|
+
[0x30000, 0x3fffd],
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
/** Whether one code point falls inside a wide range (binary search). */
|
|
116
|
+
function isWideCodePoint(code: number): boolean {
|
|
117
|
+
let low = 0
|
|
118
|
+
let high = WIDE_RANGES.length - 1
|
|
119
|
+
while (low <= high) {
|
|
120
|
+
const mid = (low + high) >> 1
|
|
121
|
+
const [start, end] = WIDE_RANGES[mid]!
|
|
122
|
+
if (code < start) high = mid - 1
|
|
123
|
+
else if (code > end) low = mid + 1
|
|
124
|
+
else return true
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Zero-width code points: combining marks, enclosing marks, format controls. */
|
|
130
|
+
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u
|
|
131
|
+
|
|
132
|
+
/** Module-cached grapheme segmenter; undefined when the runtime lacks it. */
|
|
133
|
+
let segmenter: Intl.Segmenter | undefined
|
|
134
|
+
try {
|
|
135
|
+
segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
|
|
136
|
+
} catch {
|
|
137
|
+
segmenter = undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Split text into grapheme clusters (code points when Segmenter is absent). */
|
|
141
|
+
export function splitGraphemes(text: string): string[] {
|
|
142
|
+
if (segmenter === undefined) return Array.from(text)
|
|
143
|
+
return Array.from(segmenter.segment(text), part => part.segment)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Terminal-cell width of one grapheme cluster. */
|
|
147
|
+
export function graphemeWidth(cluster: string): number {
|
|
148
|
+
// VS16 requests emoji presentation: ❤️ / ✳️ render two cells even when the
|
|
149
|
+
// base glyph is text-default (width 1 without the selector).
|
|
150
|
+
if (cluster.includes('\u{fe0f}', 0) as boolean) return 2
|
|
151
|
+
const first = cluster.codePointAt(0) ?? 0
|
|
152
|
+
if (ZERO_WIDTH.test(String.fromCodePoint(first))) return 0
|
|
153
|
+
return isWideCodePoint(first) ? 2 : 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Terminal-cell width of one code point (surrogate pairs must stay paired). */
|
|
157
|
+
export function codePointWidth(char: string): number {
|
|
158
|
+
if (ZERO_WIDTH.test(char)) return 0
|
|
159
|
+
return isWideCodePoint(char.codePointAt(0) ?? 0) ? 2 : 1
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Terminal-cell width of a string: an ASCII fast path avoids the segmenter
|
|
164
|
+
* for the overwhelmingly common case; everything else sums grapheme clusters.
|
|
165
|
+
* Control characters occupy no cells (display sanitization makes them
|
|
166
|
+
* visible escapes before they ever reach a budget).
|
|
167
|
+
* @param text - display-safe or raw text to measure.
|
|
168
|
+
* @returns the column count the terminal will draw.
|
|
169
|
+
*/
|
|
170
|
+
export function stringWidth(text: string): number {
|
|
171
|
+
let asciiOnly = true
|
|
172
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
173
|
+
if (text.charCodeAt(index) > 0x7f) {
|
|
174
|
+
asciiOnly = false
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (asciiOnly) {
|
|
179
|
+
let columns = 0
|
|
180
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
181
|
+
const code = text.charCodeAt(index)
|
|
182
|
+
if (code >= 0x20 && code !== 0x7f) columns += 1
|
|
183
|
+
}
|
|
184
|
+
return columns
|
|
185
|
+
}
|
|
186
|
+
let columns = 0
|
|
187
|
+
for (const cluster of splitGraphemes(text)) columns += graphemeWidth(cluster)
|
|
188
|
+
return columns
|
|
189
|
+
}
|
package/src/session-directory.ts
CHANGED
|
@@ -240,6 +240,62 @@ export function collectDeletionSubtree(records: readonly SessionRecord[], id: st
|
|
|
240
240
|
return [...doomed]
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/** One validated node of a deletion plan. */
|
|
244
|
+
export interface DeletionPlanNode {
|
|
245
|
+
/** Session id to remove. */
|
|
246
|
+
readonly id: string
|
|
247
|
+
/** Distance from the deletion root (0 for the root itself). */
|
|
248
|
+
readonly depth: number
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A fully preflighted subtree deletion, or the refusal that produced none. */
|
|
252
|
+
export type SessionDeletionPlan =
|
|
253
|
+
| { readonly ok: true; readonly nodes: readonly DeletionPlanNode[] }
|
|
254
|
+
| { readonly ok: false; readonly reason: string }
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Plan one session-subtree deletion with NO filesystem side effects: collect
|
|
258
|
+
* the doomed lineage, refuse when the root or ANY member is live (a live
|
|
259
|
+
* child would outlive its deleted parent) or missing from the listing, and
|
|
260
|
+
* order the result children-first so the executor can never leave a deleted
|
|
261
|
+
* parent behind surviving children. Artifact-location guards stay at the
|
|
262
|
+
* call site; this is the pure preflight they complete.
|
|
263
|
+
* @param records - the full directory listing.
|
|
264
|
+
* @param id - the root session id to delete.
|
|
265
|
+
* @returns the ordered plan, or a user-facing refusal reason.
|
|
266
|
+
*/
|
|
267
|
+
export function planSessionDeletion(records: readonly SessionRecord[], id: string): SessionDeletionPlan {
|
|
268
|
+
const target = records.find(record => record.header.id === id)
|
|
269
|
+
if (target === undefined) return { ok: false, reason: `no persisted session matches "${id}"` }
|
|
270
|
+
if (target.live) return { ok: false, reason: 'cannot delete a live session — it is open in this or another process' }
|
|
271
|
+
const doomed = collectDeletionSubtree(records, id)
|
|
272
|
+
const byId = new Map<string, SessionRecord>(records.map(record => [record.header.id, record]))
|
|
273
|
+
const parentOf = new Map<string, string | undefined>()
|
|
274
|
+
for (const record of records) parentOf.set(record.header.id, record.header.parentSession)
|
|
275
|
+
const nodes: DeletionPlanNode[] = []
|
|
276
|
+
for (const candidate of doomed) {
|
|
277
|
+
const record = byId.get(candidate)
|
|
278
|
+
if (record === undefined) return { ok: false, reason: `no persisted session matches "${candidate}"` }
|
|
279
|
+
if (record.live) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
reason: `cannot delete: child session ${candidate.slice(-12)} is live — close it (and any process using it) first`,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let depth = 0
|
|
286
|
+
let ancestor = parentOf.get(candidate)
|
|
287
|
+
while (ancestor !== undefined && depth < 64) {
|
|
288
|
+
depth += 1
|
|
289
|
+
ancestor = parentOf.get(ancestor)
|
|
290
|
+
}
|
|
291
|
+
nodes.push({ id: candidate, depth })
|
|
292
|
+
}
|
|
293
|
+
// Deepest first: a mid-deletion failure then leaves the shallowest lineage
|
|
294
|
+
// intact, never a deleted parent with surviving children.
|
|
295
|
+
nodes.sort((left, right) => right.depth - left.depth)
|
|
296
|
+
return { ok: true, nodes }
|
|
297
|
+
}
|
|
298
|
+
|
|
243
299
|
/**
|
|
244
300
|
* Codex-style relative time for session rows ("now", "5m ago", "3h ago",
|
|
245
301
|
* "2d ago"; older than a week falls back to the local date).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialized, crash-atomic persistence for the small user-level JSON files
|
|
3
|
+
* under the DSH home (statusline.json, theme.json). Two guarantees the bare
|
|
4
|
+
* floating `writeFile` path could not give:
|
|
5
|
+
*
|
|
6
|
+
* 1. Every save is appended to ONE chain, so rapid consecutive edits land
|
|
7
|
+
* in submission order and the last snapshot is the one on disk (parallel
|
|
8
|
+
* floating writes let an older snapshot finish last and win).
|
|
9
|
+
* 2. Each write goes to a sibling temp file first and is renamed into
|
|
10
|
+
* place, so a crash mid-write can never leave a half-written JSON
|
|
11
|
+
* document behind.
|
|
12
|
+
*
|
|
13
|
+
* The chain itself never rejects: a failed write is reported to that
|
|
14
|
+
* save's caller while later saves keep their turn.
|
|
15
|
+
*
|
|
16
|
+
* @module @deepseek-ai/dsh-code/settings-file
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
20
|
+
import { dirname } from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** The serialized persistence surface; flush() is handed to the quit sequence. */
|
|
23
|
+
export interface UserSettingsPersistence {
|
|
24
|
+
/**
|
|
25
|
+
* Queue one file snapshot. Resolves when the chain reaches (and renames)
|
|
26
|
+
* it; rejects only to THIS caller when its own write failed.
|
|
27
|
+
*/
|
|
28
|
+
save(path: string, text: string): Promise<void>
|
|
29
|
+
/** Wait for every queued write; safe to call repeatedly. */
|
|
30
|
+
flush(): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create the shared settings-write chain. One instance per process keeps
|
|
35
|
+
* every user-level JSON file mutually serialized.
|
|
36
|
+
* @returns the persistence handle.
|
|
37
|
+
*/
|
|
38
|
+
export function createUserSettingsPersistence(): UserSettingsPersistence {
|
|
39
|
+
let chain: Promise<void> = Promise.resolve()
|
|
40
|
+
return {
|
|
41
|
+
save(path: string, text: string): Promise<void> {
|
|
42
|
+
const write = chain.then(async () => {
|
|
43
|
+
await mkdir(dirname(path), { recursive: true })
|
|
44
|
+
const temp = `${path}.tmp`
|
|
45
|
+
await writeFile(temp, text, 'utf8')
|
|
46
|
+
await rename(temp, path)
|
|
47
|
+
})
|
|
48
|
+
// A failed write must not break the chain for later saves.
|
|
49
|
+
chain = write.catch(() => {})
|
|
50
|
+
return write
|
|
51
|
+
},
|
|
52
|
+
flush(): Promise<void> {
|
|
53
|
+
return chain
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/skills.ts
CHANGED
|
@@ -61,35 +61,48 @@ function toRows(skills: readonly SkillSummary[]): readonly SkillRow[] {
|
|
|
61
61
|
* @param ctx - context carrying the `skills` service (optional).
|
|
62
62
|
* @returns the view the completion menu subscribes to.
|
|
63
63
|
*/
|
|
64
|
-
export function watchSkills(ctx: Context): SkillsWatch {
|
|
64
|
+
export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
|
|
65
65
|
const skills = ctx.get('skills')
|
|
66
66
|
let agent: Agent | undefined
|
|
67
67
|
let rows: readonly SkillRow[] = []
|
|
68
68
|
let error: string | undefined
|
|
69
|
+
// The agent whose workspace the current rows were last successfully read
|
|
70
|
+
// from: a failure for an agent that never loaded must clear the rows, not
|
|
71
|
+
// keep another workspace's catalog answerable in this session.
|
|
72
|
+
let loadedFor: Agent | undefined
|
|
69
73
|
const listeners = new Set<() => void>()
|
|
70
74
|
|
|
71
75
|
const reload = (): void => {
|
|
72
76
|
const target = agent
|
|
73
77
|
if (skills === undefined || target === undefined) return
|
|
74
78
|
Promise.resolve().then(() => skills.list({
|
|
75
|
-
cwd: target.session.header.cwd,
|
|
79
|
+
cwd: target.session.header.cwd ?? fallbackCwd,
|
|
76
80
|
scope: target,
|
|
77
81
|
})).then((summaries: readonly SkillSummary[]) => {
|
|
78
82
|
// A retarget landed while this catalog was loading: the rows belong to
|
|
79
83
|
// another agent's workspace and must never overwrite the current view.
|
|
80
84
|
if (agent !== target) return
|
|
81
85
|
const next = toRows(summaries)
|
|
82
|
-
|
|
86
|
+
// Description and invocation-flag edits must surface too: a name-only
|
|
87
|
+
// comparison silently dropped those change notifications.
|
|
88
|
+
const unchanged = next.length === rows.length && next.every((row, index) =>
|
|
89
|
+
row.name === rows[index]?.name
|
|
90
|
+
&& row.description === rows[index]?.description
|
|
91
|
+
&& row.modelInvocable === rows[index]?.modelInvocable)
|
|
83
92
|
rows = next
|
|
93
|
+
loadedFor = target
|
|
84
94
|
const recovered = error !== undefined
|
|
85
95
|
error = undefined
|
|
86
96
|
if (unchanged && !recovered) return
|
|
87
97
|
for (const listener of listeners) listener()
|
|
88
98
|
}).catch((cause: unknown) => {
|
|
89
99
|
if (agent !== target) return
|
|
90
|
-
// Discovery failure keeps the last good rows
|
|
91
|
-
// notification is the retry surface
|
|
92
|
-
|
|
100
|
+
// Discovery failure keeps the last good rows for the SAME agent (the
|
|
101
|
+
// next skills/change notification is the retry surface, mirroring the
|
|
102
|
+
// web directory); an agent that never loaded starts from empty rows —
|
|
103
|
+
// stale rows from a previous workspace must not keep completing here.
|
|
104
|
+
if (loadedFor !== target) rows = []
|
|
105
|
+
else rows = [...rows]
|
|
93
106
|
error = cause instanceof Error ? cause.message : String(cause)
|
|
94
107
|
for (const listener of listeners) listener()
|
|
95
108
|
})
|
package/src/store.ts
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* and notifies subscribers. The renderer subscribes through
|
|
4
4
|
* `useSyncExternalStore`; the runner owns event feeding.
|
|
5
5
|
*
|
|
6
|
+
* Folding runs on the same mutable replay accumulator the persisted-log
|
|
7
|
+
* path uses (`replayProjectEvent`: id-indexed row updates, in-place
|
|
8
|
+
* appends), so a live structural event costs O(1) entry work regardless of
|
|
9
|
+
* transcript length — the copy-on-write fold rebuilt the whole entries
|
|
10
|
+
* array per event, making a growing session quadratic. An immutable
|
|
11
|
+
* `TranscriptView` snapshot is materialized only when a changed view is
|
|
12
|
+
* READ (once per rendered frame under the notification throttle, never per
|
|
13
|
+
* event), and every snapshot copies its arrays, so a view already handed
|
|
14
|
+
* out never observes later folds.
|
|
15
|
+
*
|
|
6
16
|
* Notification coalescing: the fold stays synchronous — `getView()` always
|
|
7
17
|
* returns the latest state the moment `apply` returns — but listener
|
|
8
18
|
* notification is frame-throttled (~16ms) and deduplicated. The zai/GLM
|
|
@@ -21,7 +31,7 @@
|
|
|
21
31
|
*/
|
|
22
32
|
|
|
23
33
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
24
|
-
import {
|
|
34
|
+
import { createReplayAccumulator, replayProjectEvent, snapshotReplayView, type TranscriptView } from './render/projection.ts'
|
|
25
35
|
|
|
26
36
|
/** Render frame budget: the notification cadence's upper bound. */
|
|
27
37
|
const NOTIFY_FRAME_MS = 16
|
|
@@ -49,7 +59,10 @@ export interface TranscriptStore {
|
|
|
49
59
|
* @returns the store the runner feeds and the renderer subscribes to.
|
|
50
60
|
*/
|
|
51
61
|
export function createTranscriptStore(replay?: readonly SessionEvent[]): TranscriptStore {
|
|
52
|
-
let
|
|
62
|
+
let acc = createReplayAccumulator()
|
|
63
|
+
for (const event of replay ?? []) replayProjectEvent(acc, event)
|
|
64
|
+
let view = snapshotReplayView(acc)
|
|
65
|
+
let dirty = false
|
|
53
66
|
const listeners = new Set<() => void>()
|
|
54
67
|
let scheduled = false
|
|
55
68
|
let lastNotifyAt = 0
|
|
@@ -70,7 +83,13 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
70
83
|
else setTimeout(dispatch, wait)
|
|
71
84
|
}
|
|
72
85
|
return {
|
|
73
|
-
getView: () =>
|
|
86
|
+
getView: (): TranscriptView => {
|
|
87
|
+
if (dirty) {
|
|
88
|
+
view = snapshotReplayView(acc)
|
|
89
|
+
dirty = false
|
|
90
|
+
}
|
|
91
|
+
return view
|
|
92
|
+
},
|
|
74
93
|
subscribe(listener: () => void): () => void {
|
|
75
94
|
listeners.add(listener)
|
|
76
95
|
return () => {
|
|
@@ -78,13 +97,13 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
78
97
|
}
|
|
79
98
|
},
|
|
80
99
|
apply(event: SessionEvent): void {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
view = next
|
|
100
|
+
if (!replayProjectEvent(acc, event)) return
|
|
101
|
+
dirty = true
|
|
84
102
|
notify()
|
|
85
103
|
},
|
|
86
104
|
reset(): void {
|
|
87
|
-
|
|
105
|
+
acc = createReplayAccumulator()
|
|
106
|
+
dirty = true
|
|
88
107
|
notify()
|
|
89
108
|
},
|
|
90
109
|
}
|
package/src/subagents.ts
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This is NOT a second transcript: each child folds to ONE row (label,
|
|
9
9
|
* running state, bounded last-activity text), capped at
|
|
10
|
-
* {@link MAX_SUBAGENT_ROWS}.
|
|
11
|
-
*
|
|
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
|
|
12
17
|
* by the same ~16ms frame throttle as the transcript store (per-burst
|
|
13
18
|
* microtask notify chained SyncLane rerenders past React's nested update
|
|
14
19
|
* limit; a bare macrotask merge repaints a whole turn's bursts at once).
|
|
@@ -18,7 +23,7 @@
|
|
|
18
23
|
|
|
19
24
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
20
25
|
|
|
21
|
-
/** Hard row cap:
|
|
26
|
+
/** Hard row cap: overflow evicts the oldest settled row; a fully busy feed waits. */
|
|
22
27
|
export const MAX_SUBAGENT_ROWS = 8
|
|
23
28
|
|
|
24
29
|
/** Render frame budget: the notification cadence's upper bound. */
|
|
@@ -47,6 +52,11 @@ export interface SubagentFeedView {
|
|
|
47
52
|
subscribe(listener: () => void): () => void
|
|
48
53
|
/** Read the current rows (identity-stable between changes). */
|
|
49
54
|
getSnapshot(): readonly SubagentRow[]
|
|
55
|
+
/**
|
|
56
|
+
* Distinct child sessions observed since the last reset. The row cap is
|
|
57
|
+
* a display budget, not a count of the fan-out; totals surface this.
|
|
58
|
+
*/
|
|
59
|
+
getTotalSeen(): number
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
/** Single-line bounded preview of an assembled message's text content. */
|
|
@@ -130,6 +140,7 @@ export function createSubagentFeed(): SubagentFeedView & {
|
|
|
130
140
|
reset(): void
|
|
131
141
|
} {
|
|
132
142
|
let rows: readonly SubagentRow[] = Object.freeze([])
|
|
143
|
+
const seen = new Set<string>()
|
|
133
144
|
const listeners = new Set<() => void>()
|
|
134
145
|
let scheduled = false
|
|
135
146
|
let lastNotifyAt = 0
|
|
@@ -150,12 +161,31 @@ export function createSubagentFeed(): SubagentFeedView & {
|
|
|
150
161
|
const index = rows.findIndex(row => row.id === sessionId)
|
|
151
162
|
const previous = index === -1 ? undefined : rows[index]
|
|
152
163
|
const next = foldSubagentRow(previous, sessionId, event)
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
if (index !== -1) {
|
|
165
|
+
if (next === previous) return
|
|
166
|
+
rows = Object.freeze(rows.map((row, at) => at === index ? next : row))
|
|
167
|
+
notify()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
// A child this feed has not shown yet: the honest total grows even
|
|
171
|
+
// when every row is busy; admission then prefers evicting the OLDEST
|
|
172
|
+
// settled row so a new running agent never waits on one that finished.
|
|
173
|
+
const counted = !seen.has(sessionId)
|
|
174
|
+
if (counted) seen.add(sessionId)
|
|
175
|
+
if (rows.length >= MAX_SUBAGENT_ROWS) {
|
|
176
|
+
const evict = rows.findIndex(row => row.state === 'done')
|
|
177
|
+
if (evict === -1) {
|
|
178
|
+
if (counted) notify()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
rows = Object.freeze([...rows.slice(0, evict), next, ...rows.slice(evict + 1)])
|
|
182
|
+
} else {
|
|
183
|
+
rows = Object.freeze([...rows, next])
|
|
184
|
+
}
|
|
156
185
|
notify()
|
|
157
186
|
},
|
|
158
187
|
reset(): void {
|
|
188
|
+
seen.clear()
|
|
159
189
|
if (rows.length === 0) return
|
|
160
190
|
rows = Object.freeze([])
|
|
161
191
|
notify()
|
|
@@ -169,5 +199,8 @@ export function createSubagentFeed(): SubagentFeedView & {
|
|
|
169
199
|
getSnapshot(): readonly SubagentRow[] {
|
|
170
200
|
return rows
|
|
171
201
|
},
|
|
202
|
+
getTotalSeen(): number {
|
|
203
|
+
return seen.size
|
|
204
|
+
},
|
|
172
205
|
}
|
|
173
206
|
}
|