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.
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +955 -233
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +20 -0
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/history.d.ts +10 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/locales/en.d.ts +49 -1
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/session-directory.d.ts +25 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/update-panel.d.ts +22 -1
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +288 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +5 -2
- package/src/fork.ts +11 -7
- package/src/history.ts +14 -0
- package/src/index.ts +92 -53
- package/src/input-split.ts +24 -4
- package/src/kernel-panels.ts +60 -27
- package/src/locales/en.ts +50 -1
- package/src/locales/zh.ts +50 -1
- package/src/rainbow.ts +13 -3
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +2 -1
- package/src/session-directory.ts +82 -3
- package/src/session-switch.ts +14 -0
- package/src/store.ts +19 -1
- package/src/update-panel.ts +112 -5
- package/src/version.ts +5 -0
package/src/render/status.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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
|
-
? '
|
|
515
|
-
: '
|
|
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
|
|
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: '
|
|
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
|
|
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
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
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
|
-
|
|
697
|
+
}
|
|
698
|
+
if (hint) {
|
|
699
|
+
hint = false
|
|
644
700
|
continue
|
|
645
701
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
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 (
|
|
660
|
-
|
|
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
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/session-directory.ts
CHANGED
|
@@ -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 =>
|
|
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)
|
|
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) {
|
package/src/session-switch.ts
CHANGED
|
@@ -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
|
-
|
|
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/update-panel.ts
CHANGED
|
@@ -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 () => {
|
|
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(
|
|
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
|
|