dsh-code 1.0.2 → 1.0.3

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.
@@ -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
+ }
@@ -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/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 { createTranscriptView, projectEvent, projectEvents, type TranscriptView } from './render/projection.ts'
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 view = replay === undefined ? createTranscriptView() : projectEvents(replay)
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: () => view,
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
- const next = projectEvent(view, event)
82
- if (next === view) return
83
- view = next
100
+ if (!replayProjectEvent(acc, event)) return
101
+ dirty = true
84
102
  notify()
85
103
  },
86
104
  reset(): void {
87
- view = createTranscriptView()
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}. Rows are advisory display state, rebuilt from
11
- * live events; nothing here persists or replays. Notification is coalesced
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: a fan-out larger than this stays summarized by the head. */
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 (next === previous) return
154
- if (index === -1 && rows.length >= MAX_SUBAGENT_ROWS) return
155
- rows = Object.freeze(index === -1 ? [...rows, next] : rows.map((row, at) => at === index ? next : row))
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
  }