@deepseek-harness-tui/dsh-tui 0.8.6 → 0.8.8

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.
Files changed (120) hide show
  1. package/README.md +15 -5
  2. package/bin/dsh-tui.js +269 -218
  3. package/cordis.patch.yml +48 -22
  4. package/lib/types/cc/figures.d.ts.map +1 -1
  5. package/lib/types/cc/figures.js +14 -13
  6. package/lib/types/cc/syntaxRuns.d.ts +16 -0
  7. package/lib/types/cc/syntaxRuns.d.ts.map +1 -0
  8. package/lib/types/cc/syntaxRuns.js +79 -0
  9. package/lib/types/commands.d.ts +14 -0
  10. package/lib/types/commands.d.ts.map +1 -1
  11. package/lib/types/commands.js +20 -1
  12. package/lib/types/components/Chat/SubagentMessage.d.ts +19 -0
  13. package/lib/types/components/Chat/SubagentMessage.d.ts.map +1 -0
  14. package/lib/types/components/Chat/SubagentMessage.js +75 -0
  15. package/lib/types/components/EffortInputBorder.d.ts.map +1 -1
  16. package/lib/types/components/EffortInputBorder.js +2 -2
  17. package/lib/types/components/EffortTierBadge.js +1 -1
  18. package/lib/types/components/FileSuggestions.d.ts +8 -4
  19. package/lib/types/components/FileSuggestions.d.ts.map +1 -1
  20. package/lib/types/components/FileSuggestions.js +22 -12
  21. package/lib/types/components/HelpMenu.d.ts +8 -1
  22. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  23. package/lib/types/components/HelpMenu.js +15 -4
  24. package/lib/types/components/LogoV2.d.ts +7 -1
  25. package/lib/types/components/LogoV2.d.ts.map +1 -1
  26. package/lib/types/components/LogoV2.js +13 -4
  27. package/lib/types/components/MessageList.d.ts +6 -2
  28. package/lib/types/components/MessageList.d.ts.map +1 -1
  29. package/lib/types/components/MessageList.js +17 -6
  30. package/lib/types/components/ModelPicker.js +2 -2
  31. package/lib/types/components/PromptInput.d.ts +1 -1
  32. package/lib/types/components/PromptInput.d.ts.map +1 -1
  33. package/lib/types/components/PromptInput.js +100 -36
  34. package/lib/types/components/SkillsPicker.js +2 -2
  35. package/lib/types/components/SplitDiffView.d.ts +1 -13
  36. package/lib/types/components/SplitDiffView.d.ts.map +1 -1
  37. package/lib/types/components/SplitDiffView.js +6 -105
  38. package/lib/types/components/SubagentCard.d.ts +9 -0
  39. package/lib/types/components/SubagentCard.d.ts.map +1 -0
  40. package/lib/types/components/SubagentCard.js +26 -0
  41. package/lib/types/components/SubagentDashboard.d.ts +13 -0
  42. package/lib/types/components/SubagentDashboard.d.ts.map +1 -0
  43. package/lib/types/components/SubagentDashboard.js +50 -0
  44. package/lib/types/components/SubagentDetailScene.d.ts +16 -0
  45. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -0
  46. package/lib/types/components/SubagentDetailScene.js +141 -0
  47. package/lib/types/components/SyntaxText.d.ts +15 -0
  48. package/lib/types/components/SyntaxText.d.ts.map +1 -0
  49. package/lib/types/components/SyntaxText.js +34 -0
  50. package/lib/types/components/ThinkingToggle.d.ts +3 -10
  51. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  52. package/lib/types/components/ThinkingToggle.js +4 -9
  53. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  54. package/lib/types/components/design-system/ListItem.js +1 -1
  55. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  56. package/lib/types/components/messages/AssistantThinkingMessage.js +18 -7
  57. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +2 -0
  58. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  59. package/lib/types/components/messages/AssistantToolUseMessage.js +42 -6
  60. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  61. package/lib/types/components/messages/UserPromptMessage.js +15 -2
  62. package/lib/types/dsh-adapter/channel.d.ts +64 -3
  63. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  64. package/lib/types/dsh-adapter/channel.js +387 -37
  65. package/lib/types/dsh-adapter/contract.d.ts +67 -4
  66. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  67. package/lib/types/dsh-adapter/contract.js +149 -15
  68. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  69. package/lib/types/dsh-adapter/index.js +6 -6
  70. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  71. package/lib/types/dsh-adapter/plugin.js +38 -0
  72. package/lib/types/dsh-adapter/shortcuts.js +3 -3
  73. package/lib/types/dsh-adapter/subagents.d.ts +87 -0
  74. package/lib/types/dsh-adapter/subagents.d.ts.map +1 -0
  75. package/lib/types/dsh-adapter/subagents.js +204 -0
  76. package/lib/types/herdr.d.ts +28 -0
  77. package/lib/types/herdr.d.ts.map +1 -0
  78. package/lib/types/herdr.js +133 -0
  79. package/lib/types/i18n.d.ts +233 -32
  80. package/lib/types/i18n.d.ts.map +1 -1
  81. package/lib/types/i18n.js +87 -16
  82. package/lib/types/ink/ink.d.ts +4 -0
  83. package/lib/types/ink/ink.d.ts.map +1 -1
  84. package/lib/types/ink/ink.js +84 -38
  85. package/lib/types/ink/render-node-to-output.d.ts +6 -3
  86. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  87. package/lib/types/ink/render-node-to-output.js +33 -7
  88. package/lib/types/ink/selection.d.ts +29 -4
  89. package/lib/types/ink/selection.d.ts.map +1 -1
  90. package/lib/types/ink/selection.js +41 -5
  91. package/lib/types/minimalMode.d.ts +13 -0
  92. package/lib/types/minimalMode.d.ts.map +1 -0
  93. package/lib/types/minimalMode.js +17 -0
  94. package/lib/types/screens/Chat.d.ts +1 -1
  95. package/lib/types/screens/Chat.d.ts.map +1 -1
  96. package/lib/types/screens/Chat.js +94 -43
  97. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  98. package/lib/types/screens/StatusLine.js +6 -2
  99. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -1
  100. package/lib/types/screens/TrajectoryScene.js +4 -2
  101. package/lib/types/theme.d.ts +8 -0
  102. package/lib/types/theme.d.ts.map +1 -1
  103. package/lib/types/theme.js +38 -14
  104. package/lib/types/tips.d.ts.map +1 -1
  105. package/lib/types/tips.js +25 -7
  106. package/lib/types/update.d.ts +18 -0
  107. package/lib/types/update.d.ts.map +1 -1
  108. package/lib/types/update.js +73 -2
  109. package/lib/types/utils/execFileNoThrow.d.ts +3 -20
  110. package/lib/types/utils/execFileNoThrow.d.ts.map +1 -1
  111. package/lib/types/utils/execFileNoThrow.js +53 -32
  112. package/lib/types/utils/externalEditor.d.ts +12 -11
  113. package/lib/types/utils/externalEditor.d.ts.map +1 -1
  114. package/lib/types/utils/externalEditor.js +11 -11
  115. package/lib/types/utils/fileSuggestions.d.ts +19 -0
  116. package/lib/types/utils/fileSuggestions.d.ts.map +1 -0
  117. package/lib/types/utils/fileSuggestions.js +40 -0
  118. package/lib/types/utils/mentions.d.ts.map +1 -1
  119. package/lib/types/utils/mentions.js +3 -1
  120. package/package.json +81 -74
@@ -7,11 +7,12 @@ import { SessionId } from '@deepseek-ai/dsh-session';
7
7
  import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
8
8
  import { loadBaselineInstructions } from '@deepseek-ai/dsh-agent-instructions';
9
9
  import { extname, isAbsolute, join } from 'node:path';
10
- import { completeCommands, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
10
+ import { completeCommands, HIDDEN_COMMAND_NAMES, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
11
11
  import { clearResumeTarget, forgetSession, readResumeTarget, touchSession, writeResumeTarget } from '../sessionHistory.js';
12
12
  import { appendSessionTitle, deleteSessionLog, ensureLegacySessionEventTypes, sessionsRoots } from './compat/index.js';
13
13
  import { listSummaries, locateSession, noteBranch, previewSession, } from './sessions/index.js';
14
14
  import { writeActivityFrames } from '../activityPrefs.js';
15
+ import { isPathLikeQuery, rankFileCandidates } from '../utils/fileSuggestions.js';
15
16
  import { readEffortPref, writeEffortPref } from '../effortPrefs.js';
16
17
  import { readModelPref, writeModelPref } from '../modelPrefs.js';
17
18
  import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from '../modelRoute.js';
@@ -25,8 +26,10 @@ import { extractMentions } from '../utils/mentions.js';
25
26
  import { t } from '../i18n.js';
26
27
  import { modeDisplayName, resolveSessionModes } from '../sessionModes.js';
27
28
  import { normalizeStatusBar, normalizeToolBackground } from '../tuiDisplayPrefs.js';
29
+ import { SubagentActivityStore } from './subagents.js';
28
30
  import { ActivityTracker } from 'dsh-working-activity/status';
29
31
  import { featureOn } from 'dsh-working-activity/config';
32
+ import { setMinimalMode } from '../minimalMode.js';
30
33
  import { readActivityConfig } from '../activityPrefs.js';
31
34
  import { attachSessionToWorkspace } from './workspace.js';
32
35
  import { createLocalWorkspaceRuntime, getHostWorkspaceRuntime } from './workspaces.js';
@@ -40,6 +43,7 @@ import { installDecisionGuard } from './decision-guard.js';
40
43
  import { commandOwner } from './command-attribution.js';
41
44
  import { readGrantStore } from './grants.js';
42
45
  import { hasCommandErrorCode, mapCommandError } from './command-errors.js';
46
+ import { installedMeetsVersion } from './contract.js';
43
47
  import { pluginsInfoLines } from './plugins-info.js';
44
48
  import { cleanRenderText, cleanScalarText } from './sanitize.js';
45
49
  /** `tui/input` return normalization: transform/handled/cancel or no opinion.
@@ -136,6 +140,20 @@ function normalizeRewindDoneSummary(result, warn) {
136
140
  * observation (a provider whose directory watcher is still warming).
137
141
  */
138
142
  const SKILL_COMMAND_RETRY_MS = 800;
143
+ /** Names the subagent delegation tools ship under (preset `toolName` values
144
+ * plus the CLI default); each renders as a live subagent card, never a plain
145
+ * tool card. */
146
+ const SUBAGENT_TOOL_NAMES = new Set([
147
+ 'task',
148
+ 'subagent',
149
+ 'subagent_fork',
150
+ 'subagent_claude_code',
151
+ 'subagent_codex',
152
+ 'spawn_task',
153
+ ]);
154
+ function isSubagentToolName(name) {
155
+ return SUBAGENT_TOOL_NAMES.has(name.toLowerCase());
156
+ }
139
157
  const ARGS_PREVIEW_LIMIT = 160;
140
158
  const RESULT_PREVIEW_LIMIT = 240;
141
159
  /** Local `!`-command output cap (mirrors the result preview limit). */
@@ -405,6 +423,26 @@ async function waitForTurnEnd(session, fromSeq, timeoutMs) {
405
423
  export function createChannel(ctx, initialAgent, options) {
406
424
  let agent = initialAgent;
407
425
  let currentHandle = options.handle;
426
+ const subagentControl = {
427
+ interrupt(agentId) {
428
+ const child = subagentStore.get(agentId);
429
+ const target = child?.sessionId ?? agentId;
430
+ const runtime = ctx.subagents;
431
+ if (!runtime?.interrupt || !target)
432
+ return false;
433
+ try {
434
+ runtime.interrupt(target, { kind: 'ancestor', agent });
435
+ subagentStore.onCancelled(agentId, 'interrupted');
436
+ state.subagents = subagentStore.snapshot();
437
+ syncSubagentRows();
438
+ state.emit();
439
+ return true;
440
+ }
441
+ catch {
442
+ return false;
443
+ }
444
+ },
445
+ };
408
446
  // D-7 backstop: the extensions row installs the decision-subscription
409
447
  // gate, but the channel IS the dispatch path — a stale patch without that
410
448
  // row (or a bare embed mounting neither) would otherwise leave tui/input
@@ -418,6 +456,55 @@ export function createChannel(ctx, initialAgent, options) {
418
456
  const fallbackGrantStore = readGrantStore();
419
457
  const currentGrantStore = () => ctx.get('tuiPluginHost')?.grants ?? fallbackGrantStore;
420
458
  installDecisionGuard(ctx, currentGrantStore());
459
+ // Subagent activity tracking: collects agent/subagent/*, session/event for
460
+ // subagents, and exposes live snapshots for the UI.
461
+ const subagentStore = new SubagentActivityStore();
462
+ // Subagent ChatRow tracking: maps agentId to its ChatRow for live updates.
463
+ const subagentRowsByAgentId = new Map();
464
+ // Task tool descriptions, queued in call order; each subagent/start consumes
465
+ // the oldest one so the card shows the user-visible task label.
466
+ const pendingTaskDescriptions = [];
467
+ /**
468
+ * Sync subagentStore state into ChatRows (insert/update in state.rows).
469
+ * Called whenever subagent state changes (spawned/completed/failed/output).
470
+ */
471
+ const syncSubagentRows = () => {
472
+ const snapshot = subagentStore.snapshot();
473
+ for (const sub of snapshot) {
474
+ let row = subagentRowsByAgentId.get(sub.agentId);
475
+ if (!row) {
476
+ // New subagent: insert a new ChatRow after the last user or assistant message
477
+ row = {
478
+ id: nextRowId++,
479
+ kind: 'subagent',
480
+ text: sub.description,
481
+ subagent: undefined, // will be filled below
482
+ };
483
+ subagentRowsByAgentId.set(sub.agentId, row);
484
+ state.rows.push(row);
485
+ }
486
+ const subagentRow = {
487
+ agentId: sub.agentId,
488
+ runId: sub.runId,
489
+ description: sub.description,
490
+ provider: sub.provider,
491
+ model: sub.model || 'default',
492
+ effort: sub.effort,
493
+ status: sub.status,
494
+ startedAt: sub.startedAt,
495
+ completedAt: sub.completedAt,
496
+ durationMs: sub.completedAt ? sub.completedAt - sub.startedAt : Date.now() - sub.startedAt,
497
+ outputLines: sub.output.slice(-3),
498
+ toolCalls: sub.toolCalls,
499
+ tokens: sub.tokens,
500
+ summary: sub.summary,
501
+ stopReason: sub.stopReason,
502
+ error: sub.error,
503
+ };
504
+ row.subagent = subagentRow;
505
+ row.text = sub.description;
506
+ }
507
+ };
421
508
  // The DSH slash-command registry (optional service): /plan, /goal and
422
509
  // friends register here; the TUI merges their descriptors into the slash
423
510
  // menu and dispatches through `execute` (which logs the paired
@@ -686,6 +773,12 @@ export function createChannel(ctx, initialAgent, options) {
686
773
  /** Monotonic token: only the latest `interruptAndDeliver` re-queues, so a
687
774
  * second interrupt while the abort settles cannot double-deliver. */
688
775
  let interruptSeq = 0;
776
+ // Cancellation is asynchronous: a fast second Esc can arrive after the
777
+ // driver has accepted the first abort but before its turn/end event lands.
778
+ // Do not cancel the same driver twice, or the second cancel can swallow the
779
+ // replacement work queued by interruptAndDeliver and leave the UI gated on
780
+ // a working flag that has not observed turn/end yet.
781
+ let cancelInFlight = false;
689
782
  /** The llm runtime seam (dsh-llm LlmRuntime): route metadata resolution. */
690
783
  const llmRuntime = ctx.get('llm');
691
784
  /** Mutable per-agent model selection (dsh-agent's routing override seam).
@@ -818,6 +911,17 @@ export function createChannel(ctx, initialAgent, options) {
818
911
  applyEffort(found);
819
912
  return true;
820
913
  };
914
+ /** Whether the installed command service takes composer images: version
915
+ * gate (composer images arrived on 0.1.0-rc.8 and every later family —
916
+ * 0.1.1 included — keeps the 4-param shape) with a structural fallback,
917
+ * so a failed manifest probe (bundlers, exotic loaders) still lands on
918
+ * the 4-param rc.8 shape at runtime. */
919
+ const commandServiceSupportsImages = (service) => {
920
+ if (installedMeetsVersion('@deepseek-ai/dsh-commands', '0.1.0-rc.8'))
921
+ return true;
922
+ return typeof service.execute?.length === 'number'
923
+ && service.execute.length >= 4;
924
+ };
821
925
  /** Run one DSH registry command (`/plan`, …) on the live agent; the text
822
926
  * of its result, '' when the result is textless, undefined when the
823
927
  * command is not registered, and the error message when it throws. */
@@ -864,7 +968,22 @@ export function createChannel(ctx, initialAgent, options) {
864
968
  return t('command-invoke-denied-owner', { name, owner: owner.componentId });
865
969
  }
866
970
  try {
867
- const execution = await commandService.execute(agent, `/${name}${rawInput}`, new AbortController().signal);
971
+ const signal = new AbortController().signal;
972
+ const line = `/${name}${rawInput}`;
973
+ const images = await registryCommandImages(commandService, definition, line, signal);
974
+ // rc.8 moved the signal to the 4th parameter and added composer
975
+ // images; older lines (rc.7/rc.6) take (agent, line, signal).
976
+ const execution = images === undefined
977
+ ? await commandService.execute(agent, line, signal)
978
+ : await commandService.execute(agent, line, images.images, signal);
979
+ if (images !== undefined && images.dropped.length > 0) {
980
+ // Loud-drop policy mirrors the submit pipeline (mentions-missing):
981
+ // a referenced image that never reached the command must be visible.
982
+ state.notify(t('mentions-missing', { paths: images.dropped.join(' ') }), {
983
+ color: 'warning',
984
+ timeoutMs: 4000,
985
+ });
986
+ }
868
987
  // `undefined` = not registered; a handler error surfaces as its
869
988
  // message so the user sees why the command failed.
870
989
  return execution?.result.text ?? '';
@@ -873,6 +992,51 @@ export function createChannel(ctx, initialAgent, options) {
873
992
  return error instanceof Error ? error.message : String(error);
874
993
  }
875
994
  };
995
+ /** Encode the staged `@`-mention images the user pasted for THIS command
996
+ * line into rc.8's `EncodedImageAttachment` payloads; undefined = the
997
+ * installed dsh-commands line predates composer images (rc.7/rc.6), so
998
+ * the caller uses the legacy 3-arg invoke. Matches the submit pipeline's
999
+ * token rule (expandMentions): a staged image attaches only when the
1000
+ * line references its token. A command that does not declare
1001
+ * `input.images` gets NO images — rc.8 admission settles such a batch
1002
+ * as an error, and upstream sends images only to image-capable commands.
1003
+ * A failing read drops just that image (reported via the returned
1004
+ * tokens) while the command still runs. */
1005
+ const registryCommandImages = async (service, definition, line, signal) => {
1006
+ if (!commandServiceSupportsImages(service))
1007
+ return undefined;
1008
+ const declaresImages = definition?.input?.images === true;
1009
+ if (!declaresImages || stagedImages.size === 0)
1010
+ return { images: [], dropped: [] };
1011
+ const store = mentionAttachments(ctx);
1012
+ if (typeof store?.readImage !== 'function')
1013
+ return { images: [], dropped: [] };
1014
+ const images = [];
1015
+ const dropped = [];
1016
+ for (const [token, attachment] of stagedImages) {
1017
+ if (!line.includes(token))
1018
+ continue;
1019
+ try {
1020
+ const stored = await store.readImage(attachment, signal);
1021
+ if (stored?.data instanceof Uint8Array && stored.data.byteLength > 0) {
1022
+ images.push({
1023
+ mediaType: attachment.mediaType,
1024
+ data: Buffer.from(stored.data).toString('base64'),
1025
+ name: attachment.name,
1026
+ });
1027
+ }
1028
+ else {
1029
+ dropped.push(token);
1030
+ }
1031
+ }
1032
+ catch {
1033
+ // One unreadable staged image is dropped — same loud policy as the
1034
+ // submit pipeline's mentions-missing warning (deliverUserText).
1035
+ dropped.push(token);
1036
+ }
1037
+ }
1038
+ return { images, dropped };
1039
+ };
876
1040
  // Session-mode folds: last-wins projections over the session log. The
877
1041
  // event types are registered by dsh-plan-mode / dsh-sandbox-policy /
878
1042
  // dsh-user-approval and are NOT in this package's typed SessionEvent
@@ -967,6 +1131,11 @@ export function createChannel(ctx, initialAgent, options) {
967
1131
  const index = deriveModeIndex(agent.session.events);
968
1132
  await applyMode(sessionModes[(index + 1) % sessionModes.length]);
969
1133
  };
1134
+ // Session-lifetime candidate pool for non-path queries. The load promise is
1135
+ // shared so concurrent first keystrokes cannot kick off duplicate scans, and
1136
+ // it is keyed by cwd so a /workspace switch or resumed session never reuses
1137
+ // another directory's listing.
1138
+ const fileCandidateCache = { cwd: '', load: undefined };
970
1139
  const state = {
971
1140
  effortLevels: undefined,
972
1141
  version: 0,
@@ -1001,6 +1170,8 @@ export function createChannel(ctx, initialAgent, options) {
1001
1170
  thinkingFold: options.thinkingFold ?? 'preview',
1002
1171
  toolBackground: normalizeToolBackground(options.toolBackground),
1003
1172
  statusBar: normalizeStatusBar(options.statusBar),
1173
+ whale: options.whale !== false,
1174
+ minimal: options.minimal === true,
1004
1175
  activityEnabled: options.activity !== false,
1005
1176
  contextBarEnabled: options.contextBar !== false,
1006
1177
  agentPreset: options.agentPreset,
@@ -1042,6 +1213,8 @@ export function createChannel(ctx, initialAgent, options) {
1042
1213
  thinking: 0,
1043
1214
  tools: 0,
1044
1215
  },
1216
+ subagents: [],
1217
+ subagentControl,
1045
1218
  subscribe(listener) {
1046
1219
  listeners.add(listener);
1047
1220
  return () => {
@@ -1165,6 +1338,11 @@ export function createChannel(ctx, initialAgent, options) {
1165
1338
  cancel() {
1166
1339
  // Keep the staged queue: an interrupt aborts the running turn but the
1167
1340
  // queued/steered messages are delivered as the next turn (web parity).
1341
+ // Cancellation converges asynchronously; ignore a repeated Esc/Ctrl+C
1342
+ // until the aborted turn has produced its terminal event.
1343
+ if (cancelInFlight)
1344
+ return;
1345
+ cancelInFlight = true;
1168
1346
  agent.cancel({ kind: 'user' }, { keepInbox: true });
1169
1347
  },
1170
1348
  interruptAndDeliver(texts) {
@@ -1176,8 +1354,13 @@ export function createChannel(ctx, initialAgent, options) {
1176
1354
  // followup. dsh-agent's cancel-convergence wake latch accepts this
1177
1355
  // wake immediately after cancel and starts it once the aborted turn
1178
1356
  // retires; waiting for whenIdle is unsafe because it also follows
1179
- // replacement work and may never settle.
1180
- agent.cancel({ kind: 'user' });
1357
+ // replacement work and may never settle. If cancellation is already
1358
+ // in flight, keep the existing abort and still replace the pending
1359
+ // interrupt delivery; fake/embedded agents may not emit turn/end.
1360
+ if (!cancelInFlight) {
1361
+ cancelInFlight = true;
1362
+ agent.cancel({ kind: 'user' });
1363
+ }
1181
1364
  const token = ++interruptSeq;
1182
1365
  const deliver = () => {
1183
1366
  // A second interrupt while the abort is still settling must not
@@ -1985,6 +2168,19 @@ export function createChannel(ctx, initialAgent, options) {
1985
2168
  state.statusBar = next;
1986
2169
  state.emit();
1987
2170
  },
2171
+ setWhale(visible) {
2172
+ if (visible === state.whale)
2173
+ return;
2174
+ state.whale = visible;
2175
+ state.emit();
2176
+ },
2177
+ setMinimal(enabled) {
2178
+ setMinimalMode(enabled);
2179
+ if (enabled === state.minimal)
2180
+ return;
2181
+ state.minimal = enabled;
2182
+ state.emit();
2183
+ },
1988
2184
  setActivityFrames(name) {
1989
2185
  if (!isPresetName(name)) {
1990
2186
  state.notify(t('unknown-activity-preset', { name }), { color: 'error' });
@@ -2098,13 +2294,15 @@ export function createChannel(ctx, initialAgent, options) {
2098
2294
  // snapshot() over list(): only a COMPLETE observation is authoritative
2099
2295
  // (same contract as the skill-command merge above) — a partial catalog
2100
2296
  // must surface as "failed", not as a misleading near-empty picker.
2101
- const registry = skillRegistryFor(agent);
2297
+ const target = agent;
2298
+ const registry = skillRegistryFor(target);
2102
2299
  if (registry === undefined)
2103
2300
  return [];
2104
2301
  try {
2105
- const observation = await registry.snapshot(skillViewOptions(agent));
2106
- if (!observation.complete)
2302
+ const observation = await registry.snapshot(skillViewOptions(target));
2303
+ if (target !== agent || !observation.complete) {
2107
2304
  return undefined;
2305
+ }
2108
2306
  return observation.skills.map(skill => ({
2109
2307
  name: skill.name,
2110
2308
  description: skill.description,
@@ -2269,9 +2467,33 @@ export function createChannel(ctx, initialAgent, options) {
2269
2467
  signal: options?.signal,
2270
2468
  });
2271
2469
  },
2272
- listFiles() {
2470
+ async listFileCandidates(query, options) {
2471
+ const fs = ctx.get('fs');
2472
+ if (!fs || options?.signal?.aborted)
2473
+ return [];
2474
+ if (isPathLikeQuery(query)) {
2475
+ return listPathCandidates(fs, state.cwd, query, options?.signal, options?.topK ?? 50);
2476
+ }
2477
+ if (fileCandidateCache.cwd !== state.cwd) {
2478
+ fileCandidateCache.cwd = state.cwd;
2479
+ fileCandidateCache.load = undefined;
2480
+ }
2481
+ fileCandidateCache.load ??= listFilesDeepCandidates(fs, state.cwd).then(candidates => {
2482
+ if (candidates.length > 0)
2483
+ return candidates;
2484
+ // An empty scan is not worth caching forever — retry on next query.
2485
+ fileCandidateCache.load = undefined;
2486
+ return candidates;
2487
+ });
2488
+ const candidates = await fileCandidateCache.load;
2489
+ if (options?.signal?.aborted)
2490
+ return [];
2491
+ return rankFileCandidates(candidates, query, options?.topK ?? 50);
2492
+ },
2493
+ async listFiles() {
2273
2494
  const fs = ctx.get('fs');
2274
- return listFilesDeep(fs, state.cwd);
2495
+ const candidates = await listFilesDeepCandidates(fs, state.cwd);
2496
+ return candidates.map(candidate => candidate.path);
2275
2497
  },
2276
2498
  async listSessions() {
2277
2499
  // Every stored session, classified and unfiltered. Which of them a
@@ -2717,6 +2939,10 @@ export function createChannel(ctx, initialAgent, options) {
2717
2939
  const merged = [...LOCAL_COMMANDS];
2718
2940
  if (commandService) {
2719
2941
  for (const descriptor of commandService.list(target)) {
2942
+ // Hidden TUI commands (e.g. /deepseek) stay out of the public
2943
+ // command catalog even if a plugin/skill happens to share the name.
2944
+ if (HIDDEN_COMMAND_NAMES.has(descriptor.name))
2945
+ continue;
2720
2946
  if (merged.some(command => command.name === descriptor.name))
2721
2947
  continue;
2722
2948
  const descriptions = commandTrees?.descriptions(descriptor.name);
@@ -3559,6 +3785,22 @@ ${output}
3559
3785
  // no card is a no-op below.
3560
3786
  if (event.data.name === 'ask_user_question')
3561
3787
  break;
3788
+ // The Task tool's plain card is replaced by the live subagent card
3789
+ // (Kimi Code semantics): the delegation itself renders as a subagent
3790
+ // row, so the raw args/result card would only duplicate it. The call
3791
+ // still runs - only its transcript rendering is suppressed.
3792
+ if (isSubagentToolName(event.data.name)) {
3793
+ try {
3794
+ const args = JSON.parse(event.data.arguments);
3795
+ if (typeof args.description === 'string' && args.description)
3796
+ pendingTaskDescriptions.push(args.description);
3797
+ }
3798
+ catch {
3799
+ // Unparseable args leave the queue untouched; the card falls back
3800
+ // to the provider label.
3801
+ }
3802
+ break;
3803
+ }
3562
3804
  // Reasoning that led to a tool call is done thinking — fold the
3563
3805
  // preview now, before the tool card grows the transcript past it
3564
3806
  // (see foldLiveReasoning).
@@ -3629,6 +3871,7 @@ ${output}
3629
3871
  break;
3630
3872
  }
3631
3873
  case 'turn/start': {
3874
+ cancelInFlight = false;
3632
3875
  state.working = true;
3633
3876
  state.turnStart = Date.now();
3634
3877
  state.responseChars = 0;
@@ -3644,6 +3887,7 @@ ${output}
3644
3887
  break;
3645
3888
  }
3646
3889
  case 'turn/end': {
3890
+ cancelInFlight = false;
3647
3891
  settleStreaming();
3648
3892
  state.working = false;
3649
3893
  state.activeToolCount = 0;
@@ -3910,6 +4154,19 @@ ${output}
3910
4154
  };
3911
4155
  })(),
3912
4156
  ctx.on('session/event', (session, event) => {
4157
+ // First check if this is a subagent session
4158
+ const subagentId = subagentStore.getSubagentIdBySession(session);
4159
+ if (subagentId) {
4160
+ subagentStore.onSessionEvent(subagentId, event);
4161
+ state.subagents = subagentStore.snapshot();
4162
+ syncSubagentRows();
4163
+ if (event.type === 'assistant/chunk')
4164
+ state.emitStream();
4165
+ else
4166
+ state.emit();
4167
+ return;
4168
+ }
4169
+ // Otherwise handle main agent session
3913
4170
  if (session !== agent.session)
3914
4171
  return;
3915
4172
  // Observation broker (C-042): maps user/message + assistant/message
@@ -3940,6 +4197,67 @@ ${output}
3940
4197
  else
3941
4198
  state.emit();
3942
4199
  }),
4200
+ // Subagent lifecycle tracking. The dsh-subagent service publishes scoped
4201
+ // observe-only events as `subagent/start` and `subagent/end`; the parent
4202
+ // Agent is carried by Cordis scope dispatch, not included in the payload.
4203
+ (() => {
4204
+ const disposeStart = ctx.on('subagent/start', (info) => {
4205
+ if (!info?.id)
4206
+ return;
4207
+ subagentStore.onSpawned(info.id, info.provider || 'subagent', info.provider, {
4208
+ runId: info.runId ?? info.id,
4209
+ local: info.local,
4210
+ description: pendingTaskDescriptions.shift() ?? `${info.provider || 'subagent'} task`,
4211
+ });
4212
+ // In-process providers publish a child Agent during this notification.
4213
+ // Resolve through ctx.get('agents') (the property proxy is
4214
+ // topology-sensitive); the child carries its session (live output
4215
+ // stream) and its provider/model route for the card header.
4216
+ try {
4217
+ const agents = ctx.get('agents');
4218
+ const child = agents?.get(info.id);
4219
+ if (child?.session) {
4220
+ subagentStore.linkSession(info.id, child.session);
4221
+ const model = child.options?.model ?? child.options?.provider;
4222
+ if (model)
4223
+ subagentStore.patch(info.id, { model, provider: child.options?.provider ?? info.provider });
4224
+ }
4225
+ }
4226
+ catch {
4227
+ // Session discovery is best-effort and must not break the parent turn.
4228
+ }
4229
+ state.subagents = subagentStore.snapshot();
4230
+ syncSubagentRows();
4231
+ state.emit();
4232
+ });
4233
+ const disposeEnd = ctx.on('subagent/end', (info) => {
4234
+ if (!info?.id)
4235
+ return;
4236
+ const output = Array.isArray(info.lastAssistantMessage)
4237
+ ? info.lastAssistantMessage
4238
+ .map(block => typeof block === 'object' && block !== null && 'text' in block ? String(block.text ?? '') : '')
4239
+ .filter(Boolean)
4240
+ .join('\n')
4241
+ : '';
4242
+ // The final assistant output becomes the card's summary only; the
4243
+ // running waterfall came from the child session stream, so echoing
4244
+ // it into the output buffer would duplicate it on the collapsed card.
4245
+ subagentStore.flushOutput(info.id);
4246
+ if (info.stopReason === 'completed')
4247
+ subagentStore.onCompleted(info.id, output, info.stopReason);
4248
+ else if (info.stopReason === 'cancelled' || info.stopReason === 'aborted')
4249
+ subagentStore.onCancelled(info.id, info.stopReason, output);
4250
+ else
4251
+ subagentStore.onFailed(info.id, info.stopReason || 'Unknown error');
4252
+ state.subagents = subagentStore.snapshot();
4253
+ syncSubagentRows();
4254
+ state.emit();
4255
+ });
4256
+ return () => {
4257
+ disposeStart();
4258
+ disposeEnd();
4259
+ };
4260
+ })(),
3943
4261
  ];
3944
4262
  };
3945
4263
  // Subagents inherit provider/model from AgentOptions, but resumed TUI
@@ -4086,33 +4404,64 @@ function usageOutputTokens(usage) {
4086
4404
  ? value
4087
4405
  : undefined;
4088
4406
  }
4089
- /**
4090
- * `@` file listing through the leaf's fs service (dsh-fs-local): skips
4091
- * VCS/dependency/build dirs and rotates across directory listings so one
4092
- * large subtree cannot consume the global MAX_FILES budget. That budget also
4093
- * bounds directory reads without imposing an arbitrary source-tree depth.
4094
- * Relative directories retain the trailing `/` that FileSuggestions expects.
4095
- * Unreadable subtrees are skipped, not fatal.
4096
- */
4097
- async function listFilesDeep(fs, root) {
4407
+ async function listPathCandidates(fs, cwd, query, signal, topK) {
4408
+ const normalized = query.replaceAll('\\', '/');
4409
+ const slash = normalized.lastIndexOf('/');
4410
+ // `.` / `..` without a trailing separator are whole-directory queries too.
4411
+ const bareDir = slash < 0 && (normalized === '.' || normalized === '..' || normalized === '~');
4412
+ const directoryPart = slash < 0 ? (bareDir ? `${normalized}/` : '') : normalized.slice(0, slash + 1);
4413
+ const nameQuery = slash < 0 || bareDir ? '' : normalized.slice(slash + 1);
4414
+ // `~/` expands against the host home (matches the cwd resolution rules);
4415
+ // drive-letter and POSIX-absolute prefixes pass through untouched.
4416
+ const expanded = directoryPart === '~/'
4417
+ ? `${homeDir()}/`
4418
+ : directoryPart.startsWith('/') || /^[A-Za-z]:\//.test(directoryPart)
4419
+ ? directoryPart
4420
+ : join(cwd, directoryPart || '.');
4421
+ try {
4422
+ if (signal?.aborted)
4423
+ return [];
4424
+ const target = await fs.resolve(expanded);
4425
+ const entries = (await fs.listDir(target)).slice().sort((a, b) => a.name.localeCompare(b.name));
4426
+ return rankFileCandidates(entries.filter(entry => entry.type === 'file' || entry.type === 'directory').map(entry => {
4427
+ const path = `${directoryPart}${entry.name}${entry.type === 'directory' ? '/' : ''}`;
4428
+ return { id: path, path, displayPath: path, name: entry.name, kind: entry.type, score: 0 };
4429
+ }), nameQuery, topK);
4430
+ }
4431
+ catch {
4432
+ return [];
4433
+ }
4434
+ }
4435
+ async function listFilesDeepCandidates(fs, root, signal) {
4098
4436
  if (!fs)
4099
4437
  return [];
4100
4438
  const out = [];
4101
4439
  const SKIP = new Set(['node_modules', '.git', '.hg', '.svn', '.DS_Store', 'dist']);
4102
4440
  const BUILD_DIR = /^(?:build(?:[-_].*)?|cmake-build(?:[-_].*)?)$/i;
4103
- const MAX_FILES = 100;
4104
- const directories = [{ dir: root, prefix: '', index: 0 }];
4105
- while (directories.length > 0 && out.length < MAX_FILES) {
4106
- const current = directories.shift();
4107
- if (!current)
4108
- continue;
4441
+ const queue = [{ dir: root, prefix: '', index: 0 }];
4442
+ const visited = new Set();
4443
+ const maxFiles = 100;
4444
+ const maxDirectories = 100;
4445
+ let fileCount = 0;
4446
+ let dirCount = 0;
4447
+ // Round-robin: each directory yields ONE non-skipped entry per visit before
4448
+ // it re-queues, so a large early sibling (e.g. `generated/` with 120 files)
4449
+ // cannot starve `src/` out of the per-kind budgets. This is the regression
4450
+ // contract pinned by scripts/verify-file-completion.mjs.
4451
+ while (queue.length && fileCount < maxFiles && dirCount < maxDirectories) {
4452
+ if (signal?.aborted)
4453
+ return [];
4454
+ const current = queue.shift();
4109
4455
  if (!current.entries) {
4110
4456
  try {
4111
4457
  const target = await fs.resolve(current.dir);
4112
- current.entries = await fs.listDir(target);
4458
+ if (visited.has(target.displayPath))
4459
+ continue;
4460
+ visited.add(target.displayPath);
4461
+ current.entries = (await fs.listDir(target)).slice().sort((a, b) => a.name.localeCompare(b.name));
4113
4462
  }
4114
4463
  catch {
4115
- continue; // unreadable subtree — skip
4464
+ continue;
4116
4465
  }
4117
4466
  }
4118
4467
  let entry;
@@ -4126,22 +4475,23 @@ async function listFilesDeep(fs, root) {
4126
4475
  if (!entry)
4127
4476
  continue;
4128
4477
  if (current.index < current.entries.length)
4129
- directories.push(current);
4130
- const rel = current.prefix ? `${current.prefix}/${entry.name}` : entry.name;
4478
+ queue.push(current);
4479
+ const path = current.prefix ? `${current.prefix}/${entry.name}` : entry.name;
4131
4480
  if (entry.type === 'directory') {
4132
- out.push(`${rel}/`);
4133
- directories.push({
4134
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: symlink targets optional
4135
- dir: entry.target?.displayPath ?? join(current.dir, entry.name),
4136
- prefix: rel,
4137
- index: 0,
4138
- });
4481
+ if (dirCount >= maxDirectories)
4482
+ continue;
4483
+ out.push({ id: `${path}/`, path: `${path}/`, displayPath: `${path}/`, name: entry.name, kind: 'directory', score: 0 });
4484
+ dirCount += 1;
4485
+ queue.push({ dir: entry.target?.displayPath ?? join(current.dir, entry.name), prefix: path, index: 0 });
4139
4486
  }
4140
4487
  else if (entry.type === 'file') {
4141
- out.push(rel);
4488
+ if (fileCount >= maxFiles)
4489
+ continue;
4490
+ out.push({ id: path, path, displayPath: path, name: entry.name, kind: 'file', score: 0 });
4491
+ fileCount += 1;
4142
4492
  }
4143
4493
  }
4144
- return out;
4494
+ return out.sort((a, b) => a.path.localeCompare(b.path));
4145
4495
  }
4146
4496
  /** One attached file's contribution is capped so an absent-minded `@` of a
4147
4497
  * huge file cannot blow the context window (CC caps @-attachments too). */