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/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
- /** Original web StateDot chase used by the busy composer marker. */
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, animated)
127
- // Flowing themes (prismatic, rainbow) ride their anchor walk while busy;
128
- // every other theme (and the frozen state) keeps the palette's live accent.
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 timed animations run (shimmer, chase, blink, wave). */
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 && animations)
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 timed animations (/animation [on|off])',
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': 'animations config unreadable, animations stay on: {message}',
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': 'animations save failed: {message}',
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': '开关计时动画(/animation [on|off])',
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': '动画配置无法读取,保持开启:{message}',
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': '动画保存失败:{message}',
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': '动画{state}',
373
+ 'notice.animationState': '装饰动画{state};活动指示保持开启',
374
374
  'notice.deleteFailed': '删除失败:{message}',
375
375
  'notice.modelChangedPlaceholder': '模型 → {model} · 历史图片将以文本占位符发送',
376
376
  'notice.modelNextStep': '模型 → 下一步使用 {model}',
@@ -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. */
@@ -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, ' ', 'dim'),
284
- ...prefixedTextLines(detail.truncated ? '… (output truncated)' : '(end of output)', columns, ' ', 'dim'),
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 style: LineStyle = sub.state === 'running' ? 'brand' : sub.state === 'error' ? 'error' : 'dim'
414
- const label = sub.preview === '' ? sub.name : `${sub.name} ${sub.preview}`
415
- if (sub.state === 'error' && sub.summary !== '') {
416
- return textLines(` ┆ ${mark} ${label} · ${sub.summary}${subDispatchSeconds(sub.durationMs)}`, width, style)
417
- }
418
- return textLines(` ${mark} ${label}${subDispatchSeconds(sub.durationMs)}`, width, style)
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 lines = [
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
- return showToolDetails ? lines : compactToolLines(lines, width)
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 style: LineStyle = member.outcome === 'running' ? 'brand' : member.outcome === 'failed' ? 'error' : 'dim'
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 textLines(` ┆ ${state} ${member.label}${phase}`, width, style)
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
+ }
@@ -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
- return view
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
- return false
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) return boundedRawPreview(args)
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
+ }
@@ -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 './status.ts'
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 {
@@ -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 Promise.all(records.map(async record => {
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
- if (Number.isFinite(newest)) updated.set(record.header.id, newest)
125
+ return Number.isFinite(newest) ? [record.header.id, newest] as const : undefined
93
126
  } catch {
94
- // Artifact gone or unreadable: the projection falls back to createdAt.
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;
@@ -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.