dsh-code 1.2.0 → 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.
@@ -17,7 +17,7 @@ import { singleLineText, truncateColumns, formatTokens } from './text.ts'
17
17
 
18
18
  // Re-exported for the callers that have always read the formatter here.
19
19
  export { formatTokens }
20
- import { t } from '../i18n.ts'
20
+ import { t, type MessageKey } from '../i18n.ts'
21
21
 
22
22
 
23
23
  /**
@@ -29,7 +29,11 @@ export function formatDuration(ms: number): string {
29
29
  const s = ms / 1_000
30
30
  if (s < 60) return String(Math.round(s * 10) / 10) + 's'
31
31
  const whole = Math.round(s)
32
- return Math.floor(whole / 60) + 'm' + (whole % 60) + 's'
32
+ const hours = Math.floor(whole / 3_600)
33
+ const minutes = Math.floor((whole % 3_600) / 60)
34
+ const seconds = whole % 60
35
+ if (hours > 0) return `${hours}h${minutes}m${seconds === 0 ? '' : `${seconds}s`}`
36
+ return `${minutes}m${seconds}s`
33
37
  }
34
38
 
35
39
  /**
@@ -251,6 +255,31 @@ export const STATUS_ITEMS: readonly StatusItemInfo[] = [
251
255
  { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'left' },
252
256
  ]
253
257
 
258
+ const STATUSLINE_ITEM_KEYS: Record<StatusItemId, { label: MessageKey; description: MessageKey }> = {
259
+ model: { label: 'statusline.item.model', description: 'statusline.item.model.desc' },
260
+ cwd: { label: 'statusline.item.cwd', description: 'statusline.item.cwd.desc' },
261
+ mode: { label: 'statusline.item.mode', description: 'statusline.item.mode.desc' },
262
+ branch: { label: 'statusline.item.branch', description: 'statusline.item.branch.desc' },
263
+ context: { label: 'statusline.item.context', description: 'statusline.item.context.desc' },
264
+ permission: { label: 'statusline.item.permission', description: 'statusline.item.permission.desc' },
265
+ plan: { label: 'statusline.item.plan', description: 'statusline.item.plan.desc' },
266
+ turns: { label: 'statusline.item.turns', description: 'statusline.item.turns.desc' },
267
+ durations: { label: 'statusline.item.durations', description: 'statusline.item.durations.desc' },
268
+ cache: { label: 'statusline.item.cache', description: 'statusline.item.cache.desc' },
269
+ tokens: { label: 'statusline.item.tokens', description: 'statusline.item.tokens.desc' },
270
+ title: { label: 'statusline.item.title', description: 'statusline.item.title.desc' },
271
+ goal: { label: 'statusline.item.goal', description: 'statusline.item.goal.desc' },
272
+ sandbox: { label: 'statusline.item.sandbox', description: 'statusline.item.sandbox.desc' },
273
+ }
274
+
275
+ /** Picker rows with labels/descriptions in the active interface language. */
276
+ export function localizedStatusItems(): readonly StatusItemInfo[] {
277
+ return STATUS_ITEMS.map(item => {
278
+ const keys = STATUSLINE_ITEM_KEYS[item.id]
279
+ return { ...item, label: t(keys.label), description: t(keys.description) }
280
+ })
281
+ }
282
+
254
283
  /**
255
284
  * Default order: the whole catalog (matches the pre-customization bar).
256
285
  * The busy dot is not an item — it always leads the identity cluster.
@@ -292,9 +321,10 @@ export const STATUS_ROW2_INDENT = 2
292
321
  const TITLE_BUDGET = 48
293
322
 
294
323
  /**
295
- * Primary-row drop ranks: context drops before permission; the identity
296
- * cluster never drops and ellipsizes only after the right badge is gone.
297
- * Secondary-row groups reuse the remaining ranks independently.
324
+ * Primary-row drop ranks: identity never drops; permission outranks context.
325
+ * The drop ladder peels hint and trailing identity facts before removing
326
+ * the context meter, so occupancy stays visible on a typical 120-column
327
+ * terminal. Secondary-row groups reuse the remaining ranks independently.
298
328
  */
299
329
  const RANK_TITLE = 10
300
330
  const RANK_TOKENS = 50
@@ -358,6 +388,30 @@ function sep(): StatusSpan {
358
388
  return { text: ' · ', tone: 'label' }
359
389
  }
360
390
 
391
+ /** True for the dim ` · ` that joins identity facts. */
392
+ function isIdentitySep(span: StatusSpan): boolean {
393
+ return span.text === ' · ' && span.tone === 'label'
394
+ }
395
+
396
+ /**
397
+ * Drop the trailing identity fact (branch, then mode, then cwd) so the
398
+ * context meter can keep a column. Returns undefined once only the busy
399
+ * dot and model remain.
400
+ */
401
+ function peelIdentityFact(spans: readonly StatusSpan[]): StatusSpan[] | undefined {
402
+ if (spans.length < 3) return undefined
403
+ const last = spans[spans.length - 1]
404
+ const before = spans[spans.length - 2]
405
+ const modeSep = spans[spans.length - 3]
406
+ if (last?.tone === 'accent' && modeSep !== undefined && isIdentitySep(modeSep) && before?.tone === 'label') {
407
+ return spans.slice(0, -3)
408
+ }
409
+ if ((last?.tone === 'branch' || last?.tone === 'path') && before !== undefined && isIdentitySep(before)) {
410
+ return spans.slice(0, -2)
411
+ }
412
+ return undefined
413
+ }
414
+
361
415
  /** Total visible columns of a span list (separators ride inside the spans). */
362
416
  function spansWidth(spans: readonly StatusSpan[]): number {
363
417
  let width = 0
@@ -511,8 +565,8 @@ function buildCandidates(
511
565
  group: {
512
566
  spans: [{
513
567
  text: facts.goal.phase === 'active'
514
- ? 'round ' + facts.goal.rounds + '/' + facts.goal.max
515
- : '' + safe(facts.goal.phase),
568
+ ? t('status.goal.round', { current: facts.goal.rounds, max: facts.goal.max })
569
+ : t('status.goal.phase', { phase: safe(facts.goal.phase) }),
516
570
  tone: 'accent',
517
571
  }],
518
572
  },
@@ -523,7 +577,7 @@ function buildCandidates(
523
577
  // The sandbox override stays implicit when it merely echoes the preset.
524
578
  const sandbox = safe(facts.sandbox ?? '')
525
579
  if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
526
- row2.push({ group: { spans: [{ text: 'sandbox ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' })
580
+ row2.push({ group: { spans: [{ text: t('status.label.sandbox') + ' ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' })
527
581
  }
528
582
  const permission = safe(facts.permission)
529
583
  let badge = -1
@@ -536,7 +590,7 @@ function buildCandidates(
536
590
  if (permission !== '' && enabled.has('permission')) {
537
591
  right.push({
538
592
  span: planStation
539
- ? { text: 'plan on', tone: 'plan' }
593
+ ? { text: t('status.plan.on'), tone: 'plan' }
540
594
  : { text: permission, tone: permissionTone(permission) },
541
595
  rank: RANK_BADGE,
542
596
  id: 'permission',
@@ -544,7 +598,7 @@ function buildCandidates(
544
598
  badge = right.length - 1
545
599
  }
546
600
  if (facts.plan && enabled.has('plan')) {
547
- row2.push({ group: { spans: [{ text: 'plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
601
+ row2.push({ group: { spans: [{ text: t('status.plan.mark'), tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
548
602
  }
549
603
  return { left, right, badge, row2 }
550
604
  }
@@ -552,7 +606,8 @@ function buildCandidates(
552
606
  /**
553
607
  * Compose the two-row footer layout under a column budget. Row 1 keeps model,
554
608
  * cwd, mode, branch, context, then the right-pinned permission badge and cycle
555
- * hint. It drops hint, context, and permission before ellipsizing identity.
609
+ * hint. It shrinks the context bar, drops the hint, and peels trailing
610
+ * identity facts before removing the context group or the permission badge.
556
611
  * Row 2 fits all secondary figures and state within its own budget.
557
612
  * @param facts - identity facts resolved by the runner.
558
613
  * @param stats - session figures folded from the durable log.
@@ -623,11 +678,10 @@ export function layoutStatusBar(
623
678
  }
624
679
 
625
680
  while (width() > budget) {
626
- // Context is the lowest-priority visual group: the bar shrinks inside
627
- // its own budget first (the absolute readout survives), then the
628
- // readout degrades to the bare percent, and only then does the whole
629
- // group go before the permission badge or its Shift+Tab affordance
630
- // is touched.
681
+ // Occupancy outranks the cycle hint and trailing identity facts
682
+ // (cwd/mode/branch): shrink the bar, drop the hint, then peel those
683
+ // facts before removing the context group. Permission stays until
684
+ // context is already gone.
631
685
  if (leftKept.some(entry => entry.id === 'context')) {
632
686
  if (contextWidth > CONTEXT_MIN_WIDTH) {
633
687
  const overflow = width() - budget
@@ -640,24 +694,21 @@ export function layoutStatusBar(
640
694
  rebuildContext()
641
695
  continue
642
696
  }
643
- leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
697
+ }
698
+ if (hint) {
699
+ hint = false
644
700
  continue
645
701
  }
646
- if (hint && rightKept.length > 0 && leftKept.length > 0) {
647
- const identity = leftKept[0]
648
- const identityText = identity.group.spans.map(span => span.text).join('')
649
- const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
650
- const identityBudget = budget - rightWidth - LEFT_RIGHT_GAP - visibleColumns(statusCycleHint())
651
- if (identityBudget > 0 && visibleColumns(identityText) > identityBudget) {
652
- leftKept[0] = {
653
- ...identity,
654
- group: { spans: [{ text: truncateColumns(identityText, identityBudget), tone: 'model' }] },
655
- }
702
+ const identityEntry = leftKept[0]
703
+ if (identityEntry?.id === 'identity') {
704
+ const peeled = peelIdentityFact(identityEntry.group.spans)
705
+ if (peeled !== undefined) {
706
+ leftKept[0] = { ...identityEntry, group: { spans: peeled } }
656
707
  continue
657
708
  }
658
709
  }
659
- if (hint) {
660
- hint = false
710
+ if (leftKept.some(entry => entry.id === 'context')) {
711
+ leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
661
712
  continue
662
713
  }
663
714
  let dropLeft = -1
@@ -23,7 +23,8 @@ export function formatTokens(n: number): string {
23
23
  const scaled = (v: number): string =>
24
24
  v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
25
25
  if (n < 1_000) return String(n)
26
- if (n < 1_000_000) return scaled(n / 1_000) + 'K'
26
+ // 999,500+ rounds to 1000K; step up to M instead of printing four digits.
27
+ if (n < 999_500) return scaled(n / 1_000) + 'K'
27
28
  return scaled(n / 1_000_000) + 'M'
28
29
  }
29
30
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { basename, dirname, resolve } from 'node:path'
4
4
  import { realpathSync } from 'node:fs'
5
- import { SESSION_FORMAT_VERSION, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
5
+ import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
6
6
  import { t } from './i18n.ts'
7
7
 
8
8
  export interface SessionRecord {
@@ -11,6 +11,52 @@ export interface SessionRecord {
11
11
  readonly persisted: boolean
12
12
  }
13
13
 
14
+ /** Minimal write handle retained while a planned deletion touches artifacts. */
15
+ export interface SessionDeletionLease {
16
+ close(): Promise<void>
17
+ }
18
+
19
+ /** Public persistence operation used to acquire the backend's write lease. */
20
+ export interface SessionDeletionPersistence {
21
+ open(id: SessionId, access: 'write'): Promise<SessionDeletionLease>
22
+ }
23
+
24
+ /**
25
+ * Acquire every subtree member's cross-process write lease before deleting
26
+ * any artifact. A partial acquisition is rolled back, so callers either hold
27
+ * the whole deletion boundary or touch nothing.
28
+ */
29
+ export async function acquireSessionDeletionLeases(
30
+ persistence: SessionDeletionPersistence,
31
+ ids: readonly string[],
32
+ ): Promise<readonly SessionDeletionLease[]> {
33
+ const leases: SessionDeletionLease[] = []
34
+ try {
35
+ for (const id of ids) leases.push(await persistence.open(SessionId(id), 'write'))
36
+ return leases
37
+ } catch (error: unknown) {
38
+ try {
39
+ await releaseSessionDeletionLeases(leases)
40
+ } catch (releaseError: unknown) {
41
+ throw new AggregateError([error, releaseError], 'session deletion lease acquisition and rollback failed')
42
+ }
43
+ throw error
44
+ }
45
+ }
46
+
47
+ /** Release deletion leases in reverse acquisition order. */
48
+ export async function releaseSessionDeletionLeases(leases: readonly SessionDeletionLease[]): Promise<void> {
49
+ const failures: unknown[] = []
50
+ for (const lease of [...leases].reverse()) {
51
+ try {
52
+ await lease.close()
53
+ } catch (error: unknown) {
54
+ failures.push(error)
55
+ }
56
+ }
57
+ if (failures.length > 0) throw new AggregateError(failures, 'failed to release session deletion leases')
58
+ }
59
+
14
60
  export interface TitleObservationResult {
15
61
  readonly sessionId: string
16
62
  readonly status: 'fulfilled' | 'rejected'
@@ -105,6 +151,26 @@ export function matchSessionId(headers: readonly SessionHeader[], wanted: string
105
151
  return matches[0]
106
152
  }
107
153
 
154
+ /**
155
+ * Unique picker-row match by exact id, unique prefix, or unique suffix.
156
+ * The resume list shows `id.slice(-12)`, so `/delete` arguments are often
157
+ * that tail rather than a leading prefix.
158
+ */
159
+ export function matchSessionRow(rows: readonly SessionRow[], wanted: string): SessionRow {
160
+ const needle = wanted.trim()
161
+ if (needle === '') throw new Error('no persisted session matches ""')
162
+ const exact = rows.filter(row => row.id === needle)
163
+ if (exact[0] !== undefined && exact.length === 1) return exact[0]
164
+ const prefixed = rows.filter(row => row.id.startsWith(needle))
165
+ if (prefixed[0] !== undefined && prefixed.length === 1) return prefixed[0]
166
+ const suffixed = rows.filter(row => row.id.endsWith(needle))
167
+ if (suffixed[0] !== undefined && suffixed.length === 1) return suffixed[0]
168
+ if (prefixed.length > 1 || suffixed.length > 1) {
169
+ throw new Error(`session prefix "${needle}" is ambiguous (${Math.max(prefixed.length, suffixed.length)} matches): use more of the id`)
170
+ }
171
+ throw new Error(`no persisted session matches "${needle}"`)
172
+ }
173
+
108
174
  /** The newest persisted ROOT session pinned to this cwd, or undefined. */
109
175
  export function newestRootForCwd(headers: readonly SessionHeader[], cwd: string): SessionHeader | undefined {
110
176
  const local = headers
@@ -151,12 +217,19 @@ export function projectSessionRows(
151
217
  preset: record.header.agentPreset ?? 'standard',
152
218
  }
153
219
  })
154
- .filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
220
+ .filter(row => sessionRowMatchesQuery(row, needle))
155
221
  .sort((left, right) => options.sort === 'newest'
156
222
  ? right.updatedAt - left.updatedAt || right.createdAt - left.createdAt
157
223
  : left.updatedAt - right.updatedAt || left.createdAt - right.createdAt)
158
224
  }
159
225
 
226
+ /** True when the picker query hits id, path, preset, or the displayed title. */
227
+ export function sessionRowMatchesQuery(row: Pick<SessionRow, 'id' | 'cwd' | 'workspace' | 'preset' | 'title'>, query: string): boolean {
228
+ const needle = query.trim().toLowerCase()
229
+ if (needle === '') return true
230
+ return `${row.id} ${row.cwd} ${row.workspace} ${row.preset} ${row.title ?? ''}`.toLowerCase().includes(needle)
231
+ }
232
+
160
233
  /** Merge page-local title observations without disturbing directory order. */
161
234
  export function mergeSessionTitles(
162
235
  rows: readonly SessionRow[],
@@ -300,7 +373,13 @@ export function jsonlSessionRoot(persistence: unknown): string | undefined {
300
373
  */
301
374
  export function collectDeletionSubtree(records: readonly SessionRecord[], id: string): string[] {
302
375
  const parentOf = new Map<string, string | undefined>()
303
- for (const record of records) parentOf.set(record.header.id, record.header.parentSession)
376
+ for (const record of records) {
377
+ // Only delegated subagents ride their parent's deletion. A fork is an
378
+ // independent conversation that merely shares lineage: deleting its
379
+ // origin must never take the branch's own log with it.
380
+ if (!isSubagentSession(record.header)) continue
381
+ parentOf.set(record.header.id, record.header.parentSession)
382
+ }
304
383
  const doomed = new Set<string>([id])
305
384
  // Iterate to a fixed point: children may be listed before their parents.
306
385
  for (let pass = 0; pass < 2; pass += 1) {
@@ -13,6 +13,7 @@ interface Request<T> {
13
13
  export class SessionSwitchQueue<T> {
14
14
  private pending: Request<T> | undefined
15
15
  private pumping = false
16
+ private running = false
16
17
 
17
18
  constructor(
18
19
  private readonly execute: (value: T) => Promise<void>,
@@ -34,6 +35,16 @@ export class SessionSwitchQueue<T> {
34
35
  return true
35
36
  }
36
37
 
38
+ /**
39
+ * Whether a queued change is being activated right now. Between the idle
40
+ * wait and the handoff the old session is still installed, so a submission
41
+ * made in that window would start a turn the handoff then discards; callers
42
+ * use this to refuse one instead of losing it.
43
+ */
44
+ get activating(): boolean {
45
+ return this.running
46
+ }
47
+
37
48
  private async pump(): Promise<void> {
38
49
  this.pumping = true
39
50
  try {
@@ -43,10 +54,13 @@ export class SessionSwitchQueue<T> {
43
54
  // Another request replaced this one while the turn was converging.
44
55
  if (this.pending !== observed) continue
45
56
  this.pending = undefined
57
+ this.running = true
46
58
  try {
47
59
  await this.execute(observed.value)
48
60
  } catch (error: unknown) {
49
61
  this.failed(error)
62
+ } finally {
63
+ this.running = false
50
64
  }
51
65
  }
52
66
  } finally {
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
- attemptKeys.clear()
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
  },
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { createElement, useEffect, useState, type ReactElement } from 'react'
13
13
  import { Box, Text, useInput, useStdout } from 'ink'
14
- import type { LauncherUpdateStatus } from './update.ts'
14
+ import type { LauncherUpdatePlan, LauncherUpdateStatus } from './update.ts'
15
15
  import { clampScroll, panelViewport } from './render/inspector.ts'
16
16
  import { singleLineText, truncateColumns } from './render/text.ts'
17
17
  import { panelAccent } from './panel-accent.ts'
@@ -26,6 +26,84 @@ export function clipUpdateLines(lines: readonly string[]): readonly string[] {
26
26
  return lines.length <= UPDATE_OUTPUT_CAP ? lines : lines.slice(lines.length - UPDATE_OUTPUT_CAP)
27
27
  }
28
28
 
29
+ /** One in-flight `update --apply`. Closing the panel must not start a second. */
30
+ interface UpdateApplyJob {
31
+ readonly promise: Promise<number>
32
+ readonly lines: string[]
33
+ readonly lineListeners: Set<(line: string) => void>
34
+ }
35
+
36
+ let updateApplyJob: UpdateApplyJob | undefined
37
+ const updateApplyRunningListeners = new Set<(running: boolean) => void>()
38
+
39
+ function emitUpdateApplyRunning(running: boolean): void {
40
+ for (const listener of updateApplyRunningListeners) listener(running)
41
+ }
42
+
43
+ /** True while an apply child is alive, even if the /update panel is closed. */
44
+ export function isUpdateApplyRunning(): boolean {
45
+ return updateApplyJob !== undefined
46
+ }
47
+
48
+ /** Subscribe to apply-running changes (frozen-hint "esc waits"). */
49
+ export function subscribeUpdateApplyRunning(listener: (running: boolean) => void): () => void {
50
+ updateApplyRunningListeners.add(listener)
51
+ listener(updateApplyJob !== undefined)
52
+ return () => { updateApplyRunningListeners.delete(listener) }
53
+ }
54
+
55
+ /** The live apply job, if any: lines already streamed plus the shared promise. */
56
+ export function currentUpdateApply(): UpdateApplyJob | undefined {
57
+ return updateApplyJob
58
+ }
59
+
60
+ /**
61
+ * Run `apply` once. A second call while the child is alive reuses the same
62
+ * promise and replays buffered lines — closing /update and opening it again
63
+ * must not spawn a second `npm install`.
64
+ */
65
+ export function runUpdateApply(
66
+ apply: (onLine: (line: string) => void, plan: LauncherUpdatePlan) => Promise<number>,
67
+ plan: LauncherUpdatePlan,
68
+ onLine?: (line: string) => void,
69
+ ): Promise<number> {
70
+ if (updateApplyJob !== undefined) {
71
+ if (onLine !== undefined) {
72
+ for (const line of updateApplyJob.lines) onLine(line)
73
+ updateApplyJob.lineListeners.add(onLine)
74
+ }
75
+ return updateApplyJob.promise
76
+ }
77
+ const lines: string[] = []
78
+ const lineListeners = new Set<(line: string) => void>()
79
+ if (onLine !== undefined) lineListeners.add(onLine)
80
+ const promise = apply(line => {
81
+ const text = singleLineText(line)
82
+ lines.push(text)
83
+ if (lines.length > UPDATE_OUTPUT_CAP) lines.splice(0, lines.length - UPDATE_OUTPUT_CAP)
84
+ for (const listener of lineListeners) listener(text)
85
+ }, plan)
86
+ updateApplyJob = { promise, lines, lineListeners }
87
+ emitUpdateApplyRunning(true)
88
+ const settle = (): void => {
89
+ if (updateApplyJob?.promise === promise) {
90
+ updateApplyJob = undefined
91
+ emitUpdateApplyRunning(false)
92
+ }
93
+ }
94
+ // Handle both outcomes directly. `finally()` mirrors a rejected source
95
+ // promise onto its returned promise; discarding that returned promise would
96
+ // create an unhandled rejection when the update child cannot start.
97
+ void promise.then(settle, settle)
98
+ return promise
99
+ }
100
+
101
+ /** Drop a leftover job between tests. */
102
+ export function resetUpdateApply(): void {
103
+ updateApplyJob = undefined
104
+ emitUpdateApplyRunning(false)
105
+ }
106
+
29
107
  /** One display row of the update surface. */
30
108
  export interface UpdateRow {
31
109
  readonly key: string
@@ -139,8 +217,35 @@ export function UpdatePanel({ probe, apply, close, notify }: {
139
217
  // visible row (up moves away from the tail, down onto it re-follows).
140
218
  const [anchor, setAnchor] = useState<'tail' | number>('tail')
141
219
  const [epoch, setEpoch] = useState(0)
220
+ const appendLine = (line: string): void => {
221
+ setLines(previous => clipUpdateLines([...previous, line]))
222
+ }
142
223
  useEffect(() => {
143
224
  let disposed = false
225
+ const existing = currentUpdateApply()
226
+ if (existing !== undefined) {
227
+ // The apply child outlives the panel: closing for an approval or
228
+ // Ctrl+C must reconnect to the same job, not probe-and-confirm again.
229
+ setPhase('apply')
230
+ setLines(existing.lines)
231
+ setExit(undefined)
232
+ setApplyError(undefined)
233
+ setAnchor('tail')
234
+ existing.lineListeners.add(appendLine)
235
+ void existing.promise.then(code => {
236
+ if (disposed) return
237
+ setExit(code)
238
+ setPhase('done')
239
+ }, reason => {
240
+ if (disposed) return
241
+ setApplyError(reason instanceof Error ? reason.message : String(reason))
242
+ setPhase('done')
243
+ })
244
+ return () => {
245
+ disposed = true
246
+ existing.lineListeners.delete(appendLine)
247
+ }
248
+ }
144
249
  setPhase('probe')
145
250
  setStatus(undefined)
146
251
  setProbeError(undefined)
@@ -154,21 +259,23 @@ export function UpdatePanel({ probe, apply, close, notify }: {
154
259
  setProbeError(reason instanceof Error ? reason.message : String(reason))
155
260
  setPhase('error')
156
261
  })
157
- return () => { disposed = true }
262
+ return () => {
263
+ disposed = true
264
+ currentUpdateApply()?.lineListeners.delete(appendLine)
265
+ }
158
266
  }, [epoch, probe])
159
267
  const stdout = useStdout().stdout
160
268
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
161
269
  const start = (): void => {
162
270
  if (phase !== 'plan' || status === undefined) return
163
271
  if (!updatePlanView(status).runnable) return
272
+ if (isUpdateApplyRunning()) return
164
273
  setPhase('apply')
165
274
  setLines([])
166
275
  setExit(undefined)
167
276
  setApplyError(undefined)
168
277
  setAnchor('tail')
169
- apply(line => {
170
- setLines(previous => clipUpdateLines([...previous, singleLineText(line)]))
171
- }, status.plan).then(code => {
278
+ runUpdateApply(apply, status.plan, appendLine).then(code => {
172
279
  setExit(code)
173
280
  setPhase('done')
174
281
  notify(code === 0 ? 'update installed — restart dsh to activate' : `update failed (exit ${code})`, code === 0 ? 'info' : 'error')
package/src/version.ts CHANGED
@@ -17,6 +17,11 @@ function readPackageVersion(manifest = new URL('../package.json', import.meta.ur
17
17
  /** Version of the installed dsh-code package. */
18
18
  export const DSH_CODE_VERSION = readPackageVersion()
19
19
 
20
+ /** Header brand line: hide a missing-manifest fallback so we never paint v0.0.0. */
21
+ export function headerBrandTitle(version: string = DSH_CODE_VERSION): string {
22
+ return version === '0.0.0' ? 'DeepSeek Harness' : `DeepSeek Harness · v${version}`
23
+ }
24
+
20
25
  /** The harness host package: the dsh CLI whose process runs the TUI plugin. */
21
26
  const DSH_HOST_PACKAGE_NAME = '@deepseek-ai/dsh'
22
27