dsh-code 1.4.0 → 1.5.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 +10 -8
- package/README.md +10 -8
- package/lib/index.mjs +2001 -1688
- package/lib/session-query.mjs +18 -1
- package/lib/types/app.d.ts +21 -15
- package/lib/types/composer.d.ts +1 -1
- package/lib/types/locales/en.d.ts +4 -4
- package/lib/types/render/inspector.d.ts +8 -0
- package/lib/types/render/projection-events.d.ts +33 -0
- package/lib/types/render/transcript-viewport.d.ts +32 -0
- package/lib/types/runner/session-io.d.ts +11 -0
- package/lib/types/session-query.d.ts +11 -0
- package/package.json +3 -2
- package/src/app.ts +286 -123
- package/src/composer.ts +6 -6
- package/src/locales/en.ts +4 -4
- package/src/locales/zh.ts +4 -4
- package/src/render/inspector.ts +12 -0
- package/src/render/lines.ts +45 -18
- package/src/render/projection-events.ts +109 -0
- package/src/render/projection.ts +11 -2
- package/src/render/tool-preview.ts +35 -2
- package/src/render/transcript-viewport.ts +61 -0
- package/src/render/usage.ts +1 -2
- package/src/runner/session-io.ts +43 -4
- package/src/session-query.ts +22 -0
package/src/composer.ts
CHANGED
|
@@ -121,11 +121,11 @@ function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTripl
|
|
|
121
121
|
: [palette.brandBright, palette.code, palette.brandMid]
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/** Functional busy chase; decorative color flow still follows the preference. */
|
|
125
125
|
function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
|
|
126
|
-
const tick = useFrames(BUSY_CHASE_TICK_MS,
|
|
127
|
-
// Flowing themes (prismatic, rainbow) ride their anchor walk
|
|
128
|
-
//
|
|
126
|
+
const tick = useFrames(BUSY_CHASE_TICK_MS, true)
|
|
127
|
+
// Flowing themes (prismatic, rainbow) ride their anchor walk only when
|
|
128
|
+
// decorative animation is enabled; the activity glyph always advances.
|
|
129
129
|
const flow = themeFlow()
|
|
130
130
|
const marker = flow !== undefined && animated
|
|
131
131
|
? flowColor(tick * BUSY_CHASE_TICK_MS + flow.phaseMs, flow.anchors)
|
|
@@ -592,7 +592,7 @@ export function Composer({ active, frozen, frozenHint, busy, descriptors, skills
|
|
|
592
592
|
historyFill: { text: string; index: number } | undefined
|
|
593
593
|
/** Marks the accepted entry consumed (called after the fill is applied). */
|
|
594
594
|
historyConsumed: () => void
|
|
595
|
-
/** Whether
|
|
595
|
+
/** Whether decorative animations run; caret and busy activity stay live. */
|
|
596
596
|
animations: boolean
|
|
597
597
|
/** Apply and report one /animation toggle (App persists through the runner). */
|
|
598
598
|
applyAnimations: (enabled: boolean) => void
|
|
@@ -647,7 +647,7 @@ export function Composer({ active, frozen, frozenHint, busy, descriptors, skills
|
|
|
647
647
|
const [preparingImages, setPreparingImages] = useState(false)
|
|
648
648
|
const prepareAbortRef = useRef<AbortController | undefined>(undefined)
|
|
649
649
|
const prepareEpochRef = useRef(0)
|
|
650
|
-
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages
|
|
650
|
+
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
|
|
651
651
|
useEffect(() => () => {
|
|
652
652
|
prepareEpochRef.current += 1
|
|
653
653
|
prepareAbortRef.current?.abort()
|
package/src/locales/en.ts
CHANGED
|
@@ -164,7 +164,7 @@ export const en = {
|
|
|
164
164
|
'cmd.theme': 'switch the color theme',
|
|
165
165
|
'cmd.language': 'switch the interface language (/language [en|zh])',
|
|
166
166
|
'cmd.rainbow': 'reroll or pin the rainbow palette (/rainbow [seed])',
|
|
167
|
-
'cmd.animation': 'toggle
|
|
167
|
+
'cmd.animation': 'toggle decorative animations; shimmer and activity stay live (/animation [on|off])',
|
|
168
168
|
'cmd.history': 'search and recall past prompts',
|
|
169
169
|
'cmd.queue': 'manage messages queued for the next turn',
|
|
170
170
|
'cmd.usage': 'show this session token usage',
|
|
@@ -312,12 +312,12 @@ export const en = {
|
|
|
312
312
|
'notice.imageCancelled': 'image submission cancelled',
|
|
313
313
|
'notice.themeConfigUnreadable': 'theme config unreadable, using dark: {message}',
|
|
314
314
|
'notice.languageConfigUnreadable': 'language config unreadable, using english: {message}',
|
|
315
|
-
'notice.animationsConfigUnreadable': '
|
|
315
|
+
'notice.animationsConfigUnreadable': 'decorative animation config unreadable, effects stay on: {message}',
|
|
316
316
|
'notice.statuslineConfigUnreadable': 'statusline config unreadable, using defaults: {message}',
|
|
317
317
|
'notice.sessionSearchUnavailable': 'session search is unavailable in this deployment',
|
|
318
318
|
'notice.subagentsReadOnly': 'subagent conversations are read-only',
|
|
319
319
|
'notice.statuslineSaveFailed': 'statusline save failed: {message}',
|
|
320
|
-
'notice.animationsSaveFailed': '
|
|
320
|
+
'notice.animationsSaveFailed': 'decorative animation save failed: {message}',
|
|
321
321
|
'notice.commandRegistryMissing': 'no command registry is mounted in this composition',
|
|
322
322
|
'notice.commandFallbackFailed': 'command fallback failed: {message}',
|
|
323
323
|
'notice.commandFailed': 'command failed: {message}',
|
|
@@ -368,7 +368,7 @@ export const en = {
|
|
|
368
368
|
'notice.commandAttachments': 'commands cannot carry attachments; the line will be sent to the model as a prompt',
|
|
369
369
|
'notice.copyFailed': 'copy failed: {message}',
|
|
370
370
|
'notice.vscodeKeysFailed': 'vscode-keys failed: {message}',
|
|
371
|
-
'notice.animationState': 'animations {state}',
|
|
371
|
+
'notice.animationState': 'decorative animations {state}; activity indicators stay live',
|
|
372
372
|
'notice.deleteFailed': 'delete failed: {message}',
|
|
373
373
|
'notice.modelChangedPlaceholder': 'model → {model} · image history will be sent as text placeholders',
|
|
374
374
|
'notice.modelNextStep': 'model → next step uses {model}',
|
package/src/locales/zh.ts
CHANGED
|
@@ -166,7 +166,7 @@ export const zh: MessageCatalog = {
|
|
|
166
166
|
'cmd.theme': '切换配色主题',
|
|
167
167
|
'cmd.language': '切换界面语言(/language [en|zh])',
|
|
168
168
|
'cmd.rainbow': '重掷或指定彩虹配色(/rainbow [seed])',
|
|
169
|
-
'cmd.animation': '
|
|
169
|
+
'cmd.animation': '开关装饰动画,shimmer 与活动指示始终保留(/animation [on|off])',
|
|
170
170
|
'cmd.history': '搜索并复用历史提示词',
|
|
171
171
|
'cmd.queue': '管理等待下一轮的消息',
|
|
172
172
|
'cmd.usage': '查看本会话的 token 用量',
|
|
@@ -314,12 +314,12 @@ export const zh: MessageCatalog = {
|
|
|
314
314
|
'notice.imageCancelled': '图片提交已取消',
|
|
315
315
|
'notice.themeConfigUnreadable': '主题配置无法读取,使用暗色:{message}',
|
|
316
316
|
'notice.languageConfigUnreadable': '语言配置无法读取,使用英文:{message}',
|
|
317
|
-
'notice.animationsConfigUnreadable': '
|
|
317
|
+
'notice.animationsConfigUnreadable': '装饰动画配置无法读取,保持开启:{message}',
|
|
318
318
|
'notice.statuslineConfigUnreadable': '状态栏配置无法读取,使用默认:{message}',
|
|
319
319
|
'notice.sessionSearchUnavailable': '此部署未启用会话检索',
|
|
320
320
|
'notice.subagentsReadOnly': '子代理会话为只读',
|
|
321
321
|
'notice.statuslineSaveFailed': '状态栏保存失败:{message}',
|
|
322
|
-
'notice.animationsSaveFailed': '
|
|
322
|
+
'notice.animationsSaveFailed': '装饰动画保存失败:{message}',
|
|
323
323
|
'notice.commandRegistryMissing': '当前组合未加载命令注册表',
|
|
324
324
|
'notice.commandFallbackFailed': '命令回退失败:{message}',
|
|
325
325
|
'notice.commandFailed': '命令执行失败:{message}',
|
|
@@ -370,7 +370,7 @@ export const zh: MessageCatalog = {
|
|
|
370
370
|
'notice.commandAttachments': '命令不能携带附件;该行将作为提示发送给模型',
|
|
371
371
|
'notice.copyFailed': '复制失败:{message}',
|
|
372
372
|
'notice.vscodeKeysFailed': 'vscode-keys 失败:{message}',
|
|
373
|
-
'notice.animationState': '
|
|
373
|
+
'notice.animationState': '装饰动画{state};活动指示保持开启',
|
|
374
374
|
'notice.deleteFailed': '删除失败:{message}',
|
|
375
375
|
'notice.modelChangedPlaceholder': '模型 → {model} · 历史图片将以文本占位符发送',
|
|
376
376
|
'notice.modelNextStep': '模型 → 下一步使用 {model}',
|
package/src/render/inspector.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
/** Pure viewport, selection, and scrolling rules for exclusive TUI panels. */
|
|
2
2
|
|
|
3
|
+
import type { TranscriptEntry } from './projection.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Remove assistant settlements that contain reasoning but no final response.
|
|
7
|
+
* They remain in the durable transcript and in the Ctrl/Alt+R fold; excluding
|
|
8
|
+
* them only from Ctrl+O navigation prevents short progress thoughts from
|
|
9
|
+
* appearing as a long run of misleading `reply` entries.
|
|
10
|
+
*/
|
|
11
|
+
export function inspectableTranscriptEntries(entries: readonly TranscriptEntry[]): readonly TranscriptEntry[] {
|
|
12
|
+
return entries.filter(entry => !(entry.kind === 'assistant' && entry.text.trim() === '' && entry.reasoning.trim() !== ''))
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
/** Terminal-space allocation for the inspector's one dynamic screen. */
|
|
4
16
|
export interface InspectorViewport {
|
|
5
17
|
/** Maximum dynamic rows, kept strictly below the terminal height. */
|
package/src/render/lines.ts
CHANGED
|
@@ -243,7 +243,7 @@ export function reasoningLines(text: string, columns: number): readonly StyledLi
|
|
|
243
243
|
* diff/read/web/raw rows align under them instead of floating two columns
|
|
244
244
|
* shallower.
|
|
245
245
|
*/
|
|
246
|
-
function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
|
|
246
|
+
function toolDetailLines(detail: ToolDetail, columns: number, rawStyle: LineStyle): readonly StyledLine[] {
|
|
247
247
|
switch (detail.kind) {
|
|
248
248
|
case 'diff':
|
|
249
249
|
return detail.diffs.flatMap(diff => [
|
|
@@ -280,8 +280,8 @@ function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLi
|
|
|
280
280
|
return prefixedTextLines(`${detail.url} · HTTP ${detail.statusCode}`, columns, ' ', 'dim')
|
|
281
281
|
case 'raw':
|
|
282
282
|
return [
|
|
283
|
-
...prefixedTextLines(detail.text, columns, ' ',
|
|
284
|
-
...prefixedTextLines(detail.truncated ? '… (output truncated)' : '(end of output)', columns, ' ',
|
|
283
|
+
...prefixedTextLines(detail.text, columns, ' ', rawStyle),
|
|
284
|
+
...prefixedTextLines(detail.truncated ? '… (output truncated)' : '(end of output)', columns, ' ', rawStyle),
|
|
285
285
|
]
|
|
286
286
|
default: {
|
|
287
287
|
const exhaustive: never = detail
|
|
@@ -410,12 +410,14 @@ function subDispatchLines(entry: ToolEntry, columns: number): readonly StyledLin
|
|
|
410
410
|
const width = Math.max(1, Math.floor(columns))
|
|
411
411
|
const rows = entry.subs.flatMap(sub => {
|
|
412
412
|
const mark = sub.state === 'running' ? '●' : sub.state === 'error' ? '⨯' : '⏺'
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
413
|
+
const markStyle: LineStyle = sub.state === 'running' ? 'brand' : sub.state === 'error' ? 'error' : 'success'
|
|
414
|
+
return hangingStyledLines([
|
|
415
|
+
lineSegment(`${mark} `, markStyle),
|
|
416
|
+
lineSegment(sub.name, 'brand'),
|
|
417
|
+
lineSegment(sub.preview === '' ? '' : ` ${sub.preview}`, 'dim'),
|
|
418
|
+
lineSegment(sub.state === 'error' && sub.summary !== '' ? ` · ${sub.summary}` : '', sub.state === 'error' ? 'error' : 'dim'),
|
|
419
|
+
lineSegment(subDispatchSeconds(sub.durationMs), 'dim'),
|
|
420
|
+
], width, ' ┆ ', 'dim', ' ┆ ', 'dim')
|
|
419
421
|
})
|
|
420
422
|
if (entry.subsDropped === 0) return rows
|
|
421
423
|
return [...rows, ...textLines(` ┆ … ${entry.subsDropped} earlier dispatch${entry.subsDropped === 1 ? '' : 'es'}`, width, 'dim')]
|
|
@@ -491,7 +493,7 @@ export function transcriptEntryLines(
|
|
|
491
493
|
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
492
494
|
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
493
495
|
const summaryStyle: LineStyle = entry.state === 'error' ? 'error' : 'dim'
|
|
494
|
-
const
|
|
496
|
+
const invocation = [
|
|
495
497
|
// The invocation row hangs wrapped previews under the call badge.
|
|
496
498
|
...hangingStyledLines([
|
|
497
499
|
// Global call ordinal — the same number an error line references.
|
|
@@ -505,13 +507,35 @@ export function transcriptEntryLines(
|
|
|
505
507
|
// Nested PTC sub-dispatches (run_code): one bounded row per child
|
|
506
508
|
// call, live while the parent card itself is still running.
|
|
507
509
|
...subDispatchLines(entry, width),
|
|
508
|
-
...(entry.summary === '' ? [] : hangingTextLines(
|
|
509
|
-
entry.state === 'error' ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary,
|
|
510
|
-
width, ' ⎿ ', summaryStyle, ' ', summaryStyle,
|
|
511
|
-
)),
|
|
512
|
-
...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
|
|
513
510
|
]
|
|
514
|
-
|
|
511
|
+
const summary = entry.summary === '' ? [] : hangingTextLines(
|
|
512
|
+
entry.state === 'error' ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary,
|
|
513
|
+
width, ' ⎿ ', summaryStyle, ' ', summaryStyle,
|
|
514
|
+
)
|
|
515
|
+
// Compact cards carry the bounded result summary only. Keep one fold
|
|
516
|
+
// hint when richer detail exists, but not when a complete one-line raw
|
|
517
|
+
// result is byte-for-byte the summary (that false hint was one symptom
|
|
518
|
+
// of rendering summary and detail together).
|
|
519
|
+
if (!showToolDetails) {
|
|
520
|
+
const rawFullySummarized = entry.detail?.kind === 'raw'
|
|
521
|
+
&& !entry.detail.truncated
|
|
522
|
+
&& entry.detail.text.trim() === entry.summary.trim()
|
|
523
|
+
const hiddenDetail = entry.detail !== undefined && !rawFullySummarized
|
|
524
|
+
? textLines(' … output hidden · Ctrl/Alt+R', width, 'dim').slice(0, 1)
|
|
525
|
+
: []
|
|
526
|
+
return compactToolLines([...invocation, ...summary, ...hiddenDetail], width)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const detail = entry.detail === undefined
|
|
530
|
+
? []
|
|
531
|
+
: toolDetailLines(entry.detail, width, entry.state === 'error' ? 'error' : 'dim')
|
|
532
|
+
// Raw output and structured read/diff views already contain the result
|
|
533
|
+
// represented by `summary`; web details are supplemental metadata, so
|
|
534
|
+
// those keep both the answer summary and their source/status rows.
|
|
535
|
+
const detailSupersedesSummary = entry.detail?.kind === 'raw'
|
|
536
|
+
|| entry.detail?.kind === 'read'
|
|
537
|
+
|| entry.detail?.kind === 'diff'
|
|
538
|
+
return [...invocation, ...(detailSupersedesSummary ? [] : summary), ...detail]
|
|
515
539
|
}
|
|
516
540
|
case 'command': {
|
|
517
541
|
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
@@ -537,9 +561,12 @@ export function transcriptEntryLines(
|
|
|
537
561
|
// subagent feed's live rows for the same agent.
|
|
538
562
|
...entry.members.flatMap(member => {
|
|
539
563
|
const state = member.outcome === 'running' ? '●' : member.outcome === 'failed' ? '⨯' : member.outcome === 'cancelled' ? '⏹' : '⏺'
|
|
540
|
-
const
|
|
564
|
+
const stateStyle: LineStyle = member.outcome === 'running' ? 'brand' : member.outcome === 'failed' ? 'error' : member.outcome === 'cancelled' ? 'dim' : 'success'
|
|
541
565
|
const phase = member.phase === '' ? '' : ` [${member.phase}]`
|
|
542
|
-
return
|
|
566
|
+
return hangingStyledLines([
|
|
567
|
+
lineSegment(`${state} `, stateStyle),
|
|
568
|
+
lineSegment(`${member.label}${phase}`, 'dim'),
|
|
569
|
+
], width, ' ┆ ', 'dim', ' ┆ ', 'dim')
|
|
543
570
|
}),
|
|
544
571
|
...(entry.membersDropped === 0 ? [] : textLines(` ┆ … ${entry.membersDropped} earlier member${entry.membersDropped === 1 ? '' : 's'}`, width, 'dim')),
|
|
545
572
|
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit compatibility policy for durable session events entering the TUI.
|
|
3
|
+
*
|
|
4
|
+
* The Harness event map is merge-extensible, so a reducer default branch is
|
|
5
|
+
* unavoidable: deployments may mount plugins this bundle does not know. The
|
|
6
|
+
* default must not, however, let a NEW first-party event silently bypass
|
|
7
|
+
* review. Tests compare this policy with `KNOWN_SESSION_EVENT_TYPES`; every
|
|
8
|
+
* kernel event must be deliberately classified before a Harness-line bump.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-tui/render/projection-events
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** How one durable event relates to the terminal transcript projection. */
|
|
14
|
+
export type SessionEventDisposition =
|
|
15
|
+
| 'transcript'
|
|
16
|
+
| 'other-surface'
|
|
17
|
+
| 'ignored'
|
|
18
|
+
| 'unknown'
|
|
19
|
+
|
|
20
|
+
/** Events folded directly into `TranscriptView`. */
|
|
21
|
+
export const TRANSCRIPT_SESSION_EVENT_TYPES = [
|
|
22
|
+
'agent/inbox/spliced',
|
|
23
|
+
'assistant/attempt',
|
|
24
|
+
'assistant/message',
|
|
25
|
+
'command/done',
|
|
26
|
+
'command/run',
|
|
27
|
+
'compaction/end',
|
|
28
|
+
'compaction/prune',
|
|
29
|
+
'compaction/summary',
|
|
30
|
+
'goal/change',
|
|
31
|
+
'llm/retry',
|
|
32
|
+
'llm/retry-started',
|
|
33
|
+
'permission/preset',
|
|
34
|
+
'plan/mode',
|
|
35
|
+
'request/context',
|
|
36
|
+
'request/header',
|
|
37
|
+
'sandbox/mode',
|
|
38
|
+
'schedule/change',
|
|
39
|
+
'session/title',
|
|
40
|
+
'step/start',
|
|
41
|
+
'system/message',
|
|
42
|
+
'todo/write',
|
|
43
|
+
'tool-workflow/agent-end',
|
|
44
|
+
'tool-workflow/agent-start',
|
|
45
|
+
'tool-workflow/run-end',
|
|
46
|
+
'tool-workflow/run-start',
|
|
47
|
+
'tool/call',
|
|
48
|
+
'tool/ptc-dispatch',
|
|
49
|
+
'tool/ptc-dispatch-start',
|
|
50
|
+
'tool/result',
|
|
51
|
+
'turn/end',
|
|
52
|
+
'turn/start',
|
|
53
|
+
'user/message',
|
|
54
|
+
] as const
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Events consumed by another TUI adapter rather than the transcript fold.
|
|
58
|
+
* Keeping these separate documents that they are visible behavior, not
|
|
59
|
+
* accidental no-ops.
|
|
60
|
+
*/
|
|
61
|
+
export const OTHER_SURFACE_SESSION_EVENT_TYPES = [
|
|
62
|
+
'agent-preset/selected',
|
|
63
|
+
'model/selection',
|
|
64
|
+
'subagent/catalog',
|
|
65
|
+
] as const
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Kernel events deliberately omitted from terminal presentation. Their
|
|
69
|
+
* model-visible consequences arrive through projected message/tool/session
|
|
70
|
+
* events, while audit-only facts do not need a second transcript row.
|
|
71
|
+
*/
|
|
72
|
+
export const IGNORED_SESSION_EVENT_TYPES = [
|
|
73
|
+
'approval/asked',
|
|
74
|
+
'approval/decided',
|
|
75
|
+
'approval/policy',
|
|
76
|
+
'compaction/start',
|
|
77
|
+
'deliverables/presented',
|
|
78
|
+
'feedback/message-delete',
|
|
79
|
+
'feedback/message-put',
|
|
80
|
+
'feedback/record',
|
|
81
|
+
'hook/invoked',
|
|
82
|
+
'hook/result',
|
|
83
|
+
'session-log-deepseek/delivery-accepted',
|
|
84
|
+
'session/end-seed',
|
|
85
|
+
'session/title-llm-request',
|
|
86
|
+
'step/end',
|
|
87
|
+
'subagent/descriptor',
|
|
88
|
+
'subagent/model-selection-policy',
|
|
89
|
+
'team/member',
|
|
90
|
+
'team/message/delivered',
|
|
91
|
+
'team/message/queued',
|
|
92
|
+
'team/task',
|
|
93
|
+
'web/deepseek-search-llm-request',
|
|
94
|
+
] as const
|
|
95
|
+
|
|
96
|
+
const EVENT_DISPOSITIONS = new Map<string, Exclude<SessionEventDisposition, 'unknown'>>([
|
|
97
|
+
...TRANSCRIPT_SESSION_EVENT_TYPES.map(type => [type, 'transcript'] as const),
|
|
98
|
+
...OTHER_SURFACE_SESSION_EVENT_TYPES.map(type => [type, 'other-surface'] as const),
|
|
99
|
+
...IGNORED_SESSION_EVENT_TYPES.map(type => [type, 'ignored'] as const),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify a durable event without rejecting merge-extensible plugin events.
|
|
104
|
+
* `unknown` remains a runtime no-op for forward/plugin compatibility; the
|
|
105
|
+
* known-event coverage test is what makes a first-party addition fail CI.
|
|
106
|
+
*/
|
|
107
|
+
export function sessionEventDisposition(type: string): SessionEventDisposition {
|
|
108
|
+
return EVENT_DISPOSITIONS.get(type) ?? 'unknown'
|
|
109
|
+
}
|
package/src/render/projection.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
|
12
12
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
13
13
|
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
|
|
14
14
|
import { graphemeWidth, splitGraphemes } from './width.ts'
|
|
15
|
+
import { sessionEventDisposition } from './projection-events.ts'
|
|
15
16
|
// Type-only imports merge the plugin-owned SessionEventMap variants
|
|
16
17
|
// (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
|
|
17
18
|
// plan/mode, permission/preset, sandbox/mode, session/title) into the union
|
|
@@ -796,6 +797,10 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
796
797
|
stats: { ...view.stats, contextSegments: { ...view.stats.contextSegments, system: estimateTokens(shadow.prompt) } },
|
|
797
798
|
}
|
|
798
799
|
}
|
|
800
|
+
// Known non-transcript events are deliberate no-ops. Unknown plugin events
|
|
801
|
+
// remain tolerated at runtime, while the known-event coverage test makes a
|
|
802
|
+
// new first-party kernel event fail CI until its disposition is reviewed.
|
|
803
|
+
if (sessionEventDisposition(event.type) !== 'transcript') return view
|
|
799
804
|
switch (event.type) {
|
|
800
805
|
case 'user/message': {
|
|
801
806
|
// A queued row retires when its durable user message lands (the agent
|
|
@@ -1477,7 +1482,10 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
1477
1482
|
: { ...entry, state: data.stopReason })
|
|
1478
1483
|
return { ...view, entries }
|
|
1479
1484
|
}
|
|
1480
|
-
|
|
1485
|
+
// The policy classified this event as transcript-visible, so reaching
|
|
1486
|
+
// the end means its reducer branch was omitted. Fail loudly instead of
|
|
1487
|
+
// shipping a silently stale terminal projection.
|
|
1488
|
+
throw new Error(`missing transcript projection for session event "${type}"`)
|
|
1481
1489
|
}
|
|
1482
1490
|
}
|
|
1483
1491
|
}
|
|
@@ -1694,6 +1702,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1694
1702
|
acc.systemPrompt = shadow.prompt
|
|
1695
1703
|
acc.stats = { ...acc.stats, contextSegments: { ...acc.stats.contextSegments, system: estimateTokens(shadow.prompt) } }
|
|
1696
1704
|
}
|
|
1705
|
+
if (sessionEventDisposition(event.type) !== 'transcript') return shadow.changed
|
|
1697
1706
|
switch (event.type) {
|
|
1698
1707
|
case 'user/message': {
|
|
1699
1708
|
const message = event.data
|
|
@@ -2211,7 +2220,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
2211
2220
|
({ ...entry, state: data.stopReason }))
|
|
2212
2221
|
return true
|
|
2213
2222
|
}
|
|
2214
|
-
|
|
2223
|
+
throw new Error(`missing transcript replay projection for session event "${type}"`)
|
|
2215
2224
|
}
|
|
2216
2225
|
}
|
|
2217
2226
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/** Keys searched in declaration order when building a preview. */
|
|
14
|
-
const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
|
|
14
|
+
const PREVIEW_KEYS = ['command', 'cmd', 'file_path', 'description', 'path', 'pattern', 'query', 'name'] as const
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Raw arguments longer than this are skipped without parsing and fall back
|
|
@@ -25,6 +25,33 @@ function boundedRawPreview(args: string): string {
|
|
|
25
25
|
return args.length > 80 ? `${args.slice(0, 77)}...` : args
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Escape a fixed preview key before embedding it in a bounded regex. */
|
|
29
|
+
function regexKey(key: string): string {
|
|
30
|
+
return key.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recover one complete JSON string property from the bounded head/tail of a
|
|
35
|
+
* large argument object. Write/edit payloads commonly put a multi-kilobyte
|
|
36
|
+
* `content` first and `file_path` last; parsing the whole payload is forbidden
|
|
37
|
+
* on the render path, but the short trailing path is still safe to decode.
|
|
38
|
+
*/
|
|
39
|
+
function boundedStringProperty(args: string, key: string): string | undefined {
|
|
40
|
+
const slices = [args.slice(0, MAX_PARSE_CHARS), args.slice(-MAX_PARSE_CHARS)]
|
|
41
|
+
const pattern = new RegExp(`"${regexKey(key)}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, 'u')
|
|
42
|
+
for (const slice of slices) {
|
|
43
|
+
const literal = pattern.exec(slice)?.[1]
|
|
44
|
+
if (literal === undefined) continue
|
|
45
|
+
try {
|
|
46
|
+
const value: unknown = JSON.parse(literal)
|
|
47
|
+
if (typeof value === 'string' && value !== '') return value
|
|
48
|
+
} catch {
|
|
49
|
+
// A property crossing the bounded slice edge is ignored.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
/**
|
|
29
56
|
* Resolve one bounded preview for raw tool arguments.
|
|
30
57
|
* @param args - raw JSON arguments string as the model produced it.
|
|
@@ -33,7 +60,13 @@ function boundedRawPreview(args: string): string {
|
|
|
33
60
|
*/
|
|
34
61
|
export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
35
62
|
if (args === '') return toolName
|
|
36
|
-
if (args.length > MAX_PARSE_CHARS)
|
|
63
|
+
if (args.length > MAX_PARSE_CHARS) {
|
|
64
|
+
for (const key of PREVIEW_KEYS) {
|
|
65
|
+
const value = boundedStringProperty(args, key)
|
|
66
|
+
if (value !== undefined) return value
|
|
67
|
+
}
|
|
68
|
+
return boundedRawPreview(args)
|
|
69
|
+
}
|
|
37
70
|
try {
|
|
38
71
|
const parsed: unknown = JSON.parse(args)
|
|
39
72
|
if (parsed !== null && typeof parsed === 'object') {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Physical-row viewport state for native scrollback plus a bottom-anchored tail. */
|
|
2
|
+
|
|
3
|
+
export interface TranscriptViewportCursor {
|
|
4
|
+
readonly sessionKey: string
|
|
5
|
+
readonly epoch: number
|
|
6
|
+
readonly flushedRows: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TranscriptViewportInput {
|
|
10
|
+
readonly sessionKey: string
|
|
11
|
+
readonly epoch: number
|
|
12
|
+
/** Physical durable transcript rows in the current rendered-history window. */
|
|
13
|
+
readonly totalRows: number
|
|
14
|
+
/** Maximum durable rows retained outside Static for the live viewport. */
|
|
15
|
+
readonly retainedRows: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TranscriptViewportStep {
|
|
19
|
+
readonly cursor: TranscriptViewportCursor
|
|
20
|
+
/** Durable physical rows emitted through Static. */
|
|
21
|
+
readonly staticRows: number
|
|
22
|
+
/** Durable physical rows still available to the live viewport. */
|
|
23
|
+
readonly liveRows: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Advance the monotonic physical-row flush cursor.
|
|
28
|
+
*
|
|
29
|
+
* Ordinary renders never pull a row back out of native scrollback. A source-
|
|
30
|
+
* backed replay (epoch change) flushes the complete durable window exactly
|
|
31
|
+
* once after the caller clears/remounts Static; subsequent durable rows start
|
|
32
|
+
* a fresh retained tail. Session/reset shrink starts from the new source.
|
|
33
|
+
*/
|
|
34
|
+
export function advanceTranscriptViewport(
|
|
35
|
+
previous: TranscriptViewportCursor,
|
|
36
|
+
input: TranscriptViewportInput,
|
|
37
|
+
): TranscriptViewportStep {
|
|
38
|
+
const total = Math.max(0, Math.floor(input.totalRows))
|
|
39
|
+
const retained = Math.max(0, Math.floor(input.retainedRows))
|
|
40
|
+
const desiredStatic = Math.max(0, total - retained)
|
|
41
|
+
const staticRows = previous.sessionKey !== input.sessionKey
|
|
42
|
+
? desiredStatic
|
|
43
|
+
: previous.epoch !== input.epoch
|
|
44
|
+
? total
|
|
45
|
+
: total < previous.flushedRows
|
|
46
|
+
? desiredStatic
|
|
47
|
+
: Math.max(previous.flushedRows, desiredStatic)
|
|
48
|
+
return {
|
|
49
|
+
cursor: { sessionKey: input.sessionKey, epoch: input.epoch, flushedRows: staticRows },
|
|
50
|
+
staticRows,
|
|
51
|
+
liveRows: total - staticRows,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Real history rows that fit beside the currently painted stream/tool rows. */
|
|
56
|
+
export function visibleTranscriptRows(historyRows: number, occupiedRows: number, capacity: number): number {
|
|
57
|
+
const history = Math.max(0, Math.floor(historyRows))
|
|
58
|
+
const occupied = Math.max(0, Math.floor(occupiedRows))
|
|
59
|
+
const available = Math.max(0, Math.floor(capacity) - occupied)
|
|
60
|
+
return Math.min(history, available)
|
|
61
|
+
}
|
package/src/render/usage.ts
CHANGED
|
@@ -15,8 +15,7 @@ import type { TokenUsageProjection, TurnTokenUsage } from '@deepseek-ai/dsh-toke
|
|
|
15
15
|
import { t } from '../i18n.ts'
|
|
16
16
|
import { lineSegment, type LineStyle, type StyledLine, type StyledSegment } from './lines.ts'
|
|
17
17
|
import { visibleColumns } from './markdown.ts'
|
|
18
|
-
import { formatTokens } from './
|
|
19
|
-
import { truncateColumns } from './text.ts'
|
|
18
|
+
import { formatTokens, truncateColumns } from './text.ts'
|
|
20
19
|
|
|
21
20
|
/** One completed turn's exact provider-reported accounting. */
|
|
22
21
|
export interface UsageTurn {
|
package/src/runner/session-io.ts
CHANGED
|
@@ -61,6 +61,37 @@ export interface SessionIo {
|
|
|
61
61
|
readonly loadSessionTranscript: (id: string, signal?: AbortSignal) => Promise<string>
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Maximum session artifact directories inspected at once by the picker. */
|
|
65
|
+
export const SESSION_ARTIFACT_READ_CONCURRENCY = 16
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Map a collection with a fixed worker count while preserving input order.
|
|
69
|
+
* The picker may own thousands of persisted sessions; an unbounded
|
|
70
|
+
* `Promise.all` turns those into one filesystem burst and can exhaust handles
|
|
71
|
+
* on Windows or remote filesystems.
|
|
72
|
+
*
|
|
73
|
+
* @internal Exported for the deterministic concurrency regression.
|
|
74
|
+
*/
|
|
75
|
+
export async function mapConcurrent<T, R>(
|
|
76
|
+
items: readonly T[],
|
|
77
|
+
concurrency: number,
|
|
78
|
+
mapper: (item: T, index: number) => Promise<R>,
|
|
79
|
+
): Promise<R[]> {
|
|
80
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw new RangeError('concurrency must be a positive safe integer')
|
|
81
|
+
const results = new Array<R>(items.length)
|
|
82
|
+
let next = 0
|
|
83
|
+
const worker = async (): Promise<void> => {
|
|
84
|
+
for (;;) {
|
|
85
|
+
const index = next
|
|
86
|
+
if (index >= items.length) return
|
|
87
|
+
next += 1
|
|
88
|
+
results[index] = await mapper(items[index], index)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
|
|
92
|
+
return results
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
/**
|
|
65
96
|
* Bind the session IO to one runner's services.
|
|
66
97
|
* @param services - the query engine, persistence, and the live-session probe.
|
|
@@ -80,7 +111,8 @@ export function createSessionIo(services: SessionIoServices): SessionIo {
|
|
|
80
111
|
const root = jsonlSessionRoot(persistence)
|
|
81
112
|
const updated = new Map<string, number>()
|
|
82
113
|
if (root !== undefined) {
|
|
83
|
-
await
|
|
114
|
+
const mtimes = await mapConcurrent(records, SESSION_ARTIFACT_READ_CONCURRENCY, async record => {
|
|
115
|
+
signal?.throwIfAborted()
|
|
84
116
|
try {
|
|
85
117
|
const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
|
|
86
118
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
@@ -88,12 +120,19 @@ export function createSessionIo(services: SessionIoServices): SessionIo {
|
|
|
88
120
|
entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
|
|
89
121
|
.map(entry => stat(join(dir, entry.name))),
|
|
90
122
|
)
|
|
123
|
+
signal?.throwIfAborted()
|
|
91
124
|
const newest = Math.max(...stats.map(info => info.mtimeMs))
|
|
92
|
-
|
|
125
|
+
return Number.isFinite(newest) ? [record.header.id, newest] as const : undefined
|
|
93
126
|
} catch {
|
|
94
|
-
//
|
|
127
|
+
// Cancellation must stop the picker; only a missing/unreadable
|
|
128
|
+
// artifact degrades to createdAt.
|
|
129
|
+
signal?.throwIfAborted()
|
|
130
|
+
return undefined
|
|
95
131
|
}
|
|
96
|
-
})
|
|
132
|
+
})
|
|
133
|
+
for (const pair of mtimes) {
|
|
134
|
+
if (pair !== undefined) updated.set(...pair)
|
|
135
|
+
}
|
|
97
136
|
}
|
|
98
137
|
const projected = projectSessionRows(records, { ...options, query: '' }, updated)
|
|
99
138
|
// Titles are the expensive fold. Fetch the first picker page when idle;
|
package/src/session-query.ts
CHANGED
|
@@ -224,6 +224,28 @@ export async function observeStableWithSkip(
|
|
|
224
224
|
throw new SessionQueryError('session-search persistence observation did not stabilize after one retry', 'SESSION_QUERY_PERSISTENCE_FAILED')
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Assert the private override seam this compatibility engine relies on.
|
|
229
|
+
* Harness keeps `_observeStable` private, so TypeScript cannot protect this
|
|
230
|
+
* subclass when upstream renames the method or changes its call signature.
|
|
231
|
+
* The exact-version launcher gate prevents unsupported hosts; this import-time
|
|
232
|
+
* check additionally prevents a newly built bundle from silently mounting an
|
|
233
|
+
* override the installed base will never call.
|
|
234
|
+
*
|
|
235
|
+
* @internal Exported for the compatibility regression.
|
|
236
|
+
*/
|
|
237
|
+
export function assertSessionQueryOverrideContract(candidate: unknown): void {
|
|
238
|
+
if (typeof candidate !== 'function') throw new TypeError('session-query sqlite engine must be a constructor')
|
|
239
|
+
const prototype = (candidate as { prototype?: object }).prototype
|
|
240
|
+
const descriptor = prototype === undefined ? undefined : Object.getOwnPropertyDescriptor(prototype, '_observeStable')
|
|
241
|
+
const method: unknown = descriptor?.value
|
|
242
|
+
if (typeof method !== 'function' || method.length !== 2) {
|
|
243
|
+
throw new Error('incompatible @deepseek-ai/dsh-session-query-sqlite: expected _observeStable(indexed, signal)')
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
assertSessionQueryOverrideContract(SqliteSessionQueryEngine)
|
|
248
|
+
|
|
227
249
|
// The opaque-base cast keeps the private `_observeStable` override legal in
|
|
228
250
|
// TypeScript while inheriting every runtime static (inject, Config, Service
|
|
229
251
|
// metadata) from the real engine class.
|