@deepseek-harness-tui/dsh-tui 0.8.7 → 0.9.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.
Files changed (229) hide show
  1. package/README.md +29 -10
  2. package/bin/dsh-tui.js +1 -1
  3. package/lib/types/commands.d.ts.map +1 -1
  4. package/lib/types/commands.js +4 -2
  5. package/lib/types/components/ActivityPicker.d.ts +4 -1
  6. package/lib/types/components/ActivityPicker.d.ts.map +1 -1
  7. package/lib/types/components/ActivityPicker.js +2 -2
  8. package/lib/types/components/BtwPanel.d.ts.map +1 -1
  9. package/lib/types/components/BtwPanel.js +1 -1
  10. package/lib/types/components/Chat/SubagentMessage.d.ts +2 -1
  11. package/lib/types/components/Chat/SubagentMessage.d.ts.map +1 -1
  12. package/lib/types/components/Chat/SubagentMessage.js +11 -3
  13. package/lib/types/components/CommandSuggestions.d.ts +21 -4
  14. package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
  15. package/lib/types/components/CommandSuggestions.js +41 -14
  16. package/lib/types/components/EffortSlider.d.ts +4 -1
  17. package/lib/types/components/EffortSlider.d.ts.map +1 -1
  18. package/lib/types/components/EffortSlider.js +4 -2
  19. package/lib/types/components/ExtensionDialog.js +6 -2
  20. package/lib/types/components/FileSuggestions.d.ts +26 -5
  21. package/lib/types/components/FileSuggestions.d.ts.map +1 -1
  22. package/lib/types/components/FileSuggestions.js +53 -16
  23. package/lib/types/components/GoalTodoPanel.d.ts +26 -2
  24. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  25. package/lib/types/components/GoalTodoPanel.js +86 -17
  26. package/lib/types/components/HelpMenu.d.ts +7 -1
  27. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  28. package/lib/types/components/HelpMenu.js +12 -4
  29. package/lib/types/components/HistorySearchDialog.d.ts +4 -1
  30. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  31. package/lib/types/components/HistorySearchDialog.js +2 -2
  32. package/lib/types/components/LangPicker.d.ts +14 -0
  33. package/lib/types/components/LangPicker.d.ts.map +1 -0
  34. package/lib/types/components/LangPicker.js +17 -0
  35. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  36. package/lib/types/components/LoadedContextPanel.js +3 -1
  37. package/lib/types/components/LogoV2.d.ts +5 -1
  38. package/lib/types/components/LogoV2.d.ts.map +1 -1
  39. package/lib/types/components/LogoV2.js +12 -3
  40. package/lib/types/components/Markdown.d.ts.map +1 -1
  41. package/lib/types/components/Markdown.js +12 -3
  42. package/lib/types/components/MessageList.d.ts +43 -2
  43. package/lib/types/components/MessageList.d.ts.map +1 -1
  44. package/lib/types/components/MessageList.js +553 -41
  45. package/lib/types/components/ModelPicker.d.ts +4 -1
  46. package/lib/types/components/ModelPicker.d.ts.map +1 -1
  47. package/lib/types/components/ModelPicker.js +2 -2
  48. package/lib/types/components/PermissionsPicker.d.ts +21 -0
  49. package/lib/types/components/PermissionsPicker.d.ts.map +1 -0
  50. package/lib/types/components/PermissionsPicker.js +27 -0
  51. package/lib/types/components/PlanPicker.d.ts +15 -0
  52. package/lib/types/components/PlanPicker.d.ts.map +1 -0
  53. package/lib/types/components/PlanPicker.js +18 -0
  54. package/lib/types/components/PresetPicker.d.ts +4 -1
  55. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  56. package/lib/types/components/PresetPicker.js +2 -2
  57. package/lib/types/components/PromptInput.d.ts +4 -3
  58. package/lib/types/components/PromptInput.d.ts.map +1 -1
  59. package/lib/types/components/PromptInput.js +273 -50
  60. package/lib/types/components/RewindPicker.d.ts +12 -1
  61. package/lib/types/components/RewindPicker.d.ts.map +1 -1
  62. package/lib/types/components/RewindPicker.js +6 -4
  63. package/lib/types/components/ScrollbarGutter.d.ts +28 -0
  64. package/lib/types/components/ScrollbarGutter.d.ts.map +1 -0
  65. package/lib/types/components/ScrollbarGutter.js +68 -0
  66. package/lib/types/components/Select.d.ts +8 -1
  67. package/lib/types/components/Select.d.ts.map +1 -1
  68. package/lib/types/components/Select.js +4 -2
  69. package/lib/types/components/SkillsPicker.d.ts +4 -1
  70. package/lib/types/components/SkillsPicker.d.ts.map +1 -1
  71. package/lib/types/components/SkillsPicker.js +2 -2
  72. package/lib/types/components/SubagentCard.d.ts +2 -1
  73. package/lib/types/components/SubagentCard.d.ts.map +1 -1
  74. package/lib/types/components/SubagentCard.js +6 -1
  75. package/lib/types/components/SubagentDashboard.d.ts +4 -0
  76. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  77. package/lib/types/components/SubagentDashboard.js +9 -1
  78. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
  79. package/lib/types/components/SubagentDetailScene.js +5 -4
  80. package/lib/types/components/SuggestionCard.d.ts +63 -0
  81. package/lib/types/components/SuggestionCard.d.ts.map +1 -0
  82. package/lib/types/components/SuggestionCard.js +77 -0
  83. package/lib/types/components/ThemePicker.d.ts +4 -1
  84. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  85. package/lib/types/components/ThemePicker.js +2 -2
  86. package/lib/types/components/ThinkingToggle.d.ts +4 -1
  87. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  88. package/lib/types/components/ThinkingToggle.js +2 -2
  89. package/lib/types/components/TimelineRail.d.ts +53 -0
  90. package/lib/types/components/TimelineRail.d.ts.map +1 -0
  91. package/lib/types/components/TimelineRail.js +190 -0
  92. package/lib/types/components/WorkspaceFlowPicker.d.ts +6 -1
  93. package/lib/types/components/WorkspaceFlowPicker.d.ts.map +1 -1
  94. package/lib/types/components/WorkspaceFlowPicker.js +4 -2
  95. package/lib/types/components/WorkspaceMenuPicker.d.ts +19 -0
  96. package/lib/types/components/WorkspaceMenuPicker.d.ts.map +1 -0
  97. package/lib/types/components/WorkspaceMenuPicker.js +19 -0
  98. package/lib/types/components/WorkspacePicker.d.ts +4 -1
  99. package/lib/types/components/WorkspacePicker.d.ts.map +1 -1
  100. package/lib/types/components/WorkspacePicker.js +2 -2
  101. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  102. package/lib/types/components/approvals/ApprovalPanel.js +5 -1
  103. package/lib/types/components/design-system/ListItem.d.ts +8 -1
  104. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  105. package/lib/types/components/design-system/ListItem.js +8 -3
  106. package/lib/types/components/messages/AssistantTextMessage.d.ts +5 -2
  107. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -1
  108. package/lib/types/components/messages/AssistantTextMessage.js +6 -2
  109. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -1
  110. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  111. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -4
  112. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +8 -1
  113. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  114. package/lib/types/components/messages/AssistantToolUseMessage.js +4 -2
  115. package/lib/types/components/messages/UserPromptMessage.d.ts +2 -1
  116. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  117. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  118. package/lib/types/components/questions/AskUserQuestionPanel.js +34 -2
  119. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  120. package/lib/types/components/questions/PlanReviewPanel.js +13 -2
  121. package/lib/types/components/sessions/SessionListRow.d.ts +4 -1
  122. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  123. package/lib/types/components/sessions/SessionListRow.js +4 -2
  124. package/lib/types/components/trajectory/HotspotView.d.ts +7 -1
  125. package/lib/types/components/trajectory/HotspotView.d.ts.map +1 -1
  126. package/lib/types/components/trajectory/HotspotView.js +8 -2
  127. package/lib/types/components/trajectory/Ledger.d.ts +9 -1
  128. package/lib/types/components/trajectory/Ledger.d.ts.map +1 -1
  129. package/lib/types/components/trajectory/Ledger.js +23 -5
  130. package/lib/types/components/trajectory/WaveBand.d.ts +10 -1
  131. package/lib/types/components/trajectory/WaveBand.d.ts.map +1 -1
  132. package/lib/types/components/trajectory/WaveBand.js +7 -2
  133. package/lib/types/dsh-adapter/channel.d.ts +27 -6
  134. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  135. package/lib/types/dsh-adapter/channel.js +432 -66
  136. package/lib/types/dsh-adapter/contract.d.ts +58 -18
  137. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  138. package/lib/types/dsh-adapter/contract.js +124 -36
  139. package/lib/types/dsh-adapter/index.d.ts +10 -2
  140. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  141. package/lib/types/dsh-adapter/index.js +10 -13
  142. package/lib/types/dsh-adapter/plugin.d.ts +0 -9
  143. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  144. package/lib/types/dsh-adapter/plugin.js +136 -6
  145. package/lib/types/i18n.d.ts +194 -16
  146. package/lib/types/i18n.d.ts.map +1 -1
  147. package/lib/types/i18n.js +73 -10
  148. package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -1
  149. package/lib/types/ink/components/AlternateScreen.js +13 -1
  150. package/lib/types/ink/components/App.d.ts +12 -1
  151. package/lib/types/ink/components/App.d.ts.map +1 -1
  152. package/lib/types/ink/components/App.js +101 -4
  153. package/lib/types/ink/components/ClockContext.d.ts.map +1 -1
  154. package/lib/types/ink/components/ClockContext.js +2 -0
  155. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  156. package/lib/types/ink/components/ScrollBox.js +170 -91
  157. package/lib/types/ink/constants.d.ts +9 -0
  158. package/lib/types/ink/constants.d.ts.map +1 -1
  159. package/lib/types/ink/constants.js +9 -0
  160. package/lib/types/ink/events/click-event.d.ts +7 -16
  161. package/lib/types/ink/events/click-event.d.ts.map +1 -1
  162. package/lib/types/ink/events/click-event.js +16 -19
  163. package/lib/types/ink/events/event-handlers.d.ts +11 -1
  164. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  165. package/lib/types/ink/events/event-handlers.js +2 -0
  166. package/lib/types/ink/events/input-event.d.ts +9 -0
  167. package/lib/types/ink/events/input-event.d.ts.map +1 -1
  168. package/lib/types/ink/events/input-event.js +4 -0
  169. package/lib/types/ink/events/pointer-event.d.ts +61 -0
  170. package/lib/types/ink/events/pointer-event.d.ts.map +1 -0
  171. package/lib/types/ink/events/pointer-event.js +61 -0
  172. package/lib/types/ink/events/wheel-event.d.ts +17 -0
  173. package/lib/types/ink/events/wheel-event.d.ts.map +1 -0
  174. package/lib/types/ink/events/wheel-event.js +20 -0
  175. package/lib/types/ink/geometry-trace.d.ts +51 -0
  176. package/lib/types/ink/geometry-trace.d.ts.map +1 -0
  177. package/lib/types/ink/geometry-trace.js +83 -0
  178. package/lib/types/ink/hit-test.d.ts +56 -1
  179. package/lib/types/ink/hit-test.d.ts.map +1 -1
  180. package/lib/types/ink/hit-test.js +146 -8
  181. package/lib/types/ink/ink.d.ts +58 -1
  182. package/lib/types/ink/ink.d.ts.map +1 -1
  183. package/lib/types/ink/ink.js +268 -52
  184. package/lib/types/ink/node-cache.d.ts +5 -0
  185. package/lib/types/ink/node-cache.d.ts.map +1 -1
  186. package/lib/types/ink/parse-keypress.d.ts +8 -0
  187. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  188. package/lib/types/ink/parse-keypress.js +101 -12
  189. package/lib/types/ink/render-node-to-output.d.ts +16 -3
  190. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  191. package/lib/types/ink/render-node-to-output.js +144 -25
  192. package/lib/types/ink/selection.d.ts +29 -4
  193. package/lib/types/ink/selection.d.ts.map +1 -1
  194. package/lib/types/ink/selection.js +41 -5
  195. package/lib/types/ink/timeline-rail.d.ts +138 -0
  196. package/lib/types/ink/timeline-rail.d.ts.map +1 -0
  197. package/lib/types/ink/timeline-rail.js +179 -0
  198. package/lib/types/ink/wrap-text.d.ts.map +1 -1
  199. package/lib/types/ink/wrap-text.js +47 -5
  200. package/lib/types/screens/Chat.d.ts.map +1 -1
  201. package/lib/types/screens/Chat.js +819 -337
  202. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  203. package/lib/types/screens/SessionBrowser.js +56 -29
  204. package/lib/types/screens/Settings.d.ts.map +1 -1
  205. package/lib/types/screens/Settings.js +64 -22
  206. package/lib/types/screens/StatusLine.d.ts +2 -1
  207. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  208. package/lib/types/screens/StatusLine.js +17 -1
  209. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -1
  210. package/lib/types/screens/TrajectoryScene.js +81 -14
  211. package/lib/types/screens/chatOverlay.d.ts +221 -0
  212. package/lib/types/screens/chatOverlay.d.ts.map +1 -0
  213. package/lib/types/screens/chatOverlay.js +114 -0
  214. package/lib/types/tips.d.ts.map +1 -1
  215. package/lib/types/tips.js +64 -10
  216. package/lib/types/tuiDisplayPrefs.d.ts +15 -0
  217. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  218. package/lib/types/tuiDisplayPrefs.js +9 -0
  219. package/lib/types/utils/debug.d.ts +10 -0
  220. package/lib/types/utils/debug.d.ts.map +1 -1
  221. package/lib/types/utils/debug.js +24 -0
  222. package/lib/types/utils/fileSuggestions.d.ts +19 -0
  223. package/lib/types/utils/fileSuggestions.d.ts.map +1 -0
  224. package/lib/types/utils/fileSuggestions.js +40 -0
  225. package/lib/types/utils/mentions.d.ts.map +1 -1
  226. package/lib/types/utils/mentions.js +3 -1
  227. package/lib/types/utils/paths.d.ts.map +1 -1
  228. package/lib/types/utils/paths.js +1 -0
  229. package/package.json +69 -66
@@ -12,19 +12,22 @@ import { clearResumeTarget, forgetSession, readResumeTarget, touchSession, write
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';
18
19
  import { readPresetPref, writePresetPref } from '../presetPrefs.js';
19
20
  import { composePreset, resolvePersistedPreset, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
20
- import { isPresetName } from '../components/activityFrames.js';
21
+ import { isPresetName, PRESET_NAMES } from '../components/activityFrames.js';
21
22
  import { existsSync, statSync, writeFileSync } from 'node:fs';
22
23
  import { logForDebugging } from '../utils/debug.js';
23
24
  import { homeDir, LEGACY_DATA_DIR } from '../utils/paths.js';
24
25
  import { extractMentions } from '../utils/mentions.js';
25
- import { t } from '../i18n.js';
26
+ import { getLang, LANGS, t } from '../i18n.js';
27
+ import { AUTO_THEME_NAME, THEME_NAMES } from '../theme.js';
28
+ import { listCustomThemes } from '../customTheme.js';
26
29
  import { modeDisplayName, resolveSessionModes } from '../sessionModes.js';
27
- import { normalizeStatusBar, normalizeToolBackground } from '../tuiDisplayPrefs.js';
30
+ import { normalizeScrollGutter, normalizeStatusBar, normalizeToolBackground } from '../tuiDisplayPrefs.js';
28
31
  import { SubagentActivityStore } from './subagents.js';
29
32
  import { ActivityTracker } from 'dsh-working-activity/status';
30
33
  import { featureOn } from 'dsh-working-activity/config';
@@ -42,7 +45,7 @@ import { installDecisionGuard } from './decision-guard.js';
42
45
  import { commandOwner } from './command-attribution.js';
43
46
  import { readGrantStore } from './grants.js';
44
47
  import { hasCommandErrorCode, mapCommandError } from './command-errors.js';
45
- import { installedLineOf } from './contract.js';
48
+ import { installedMeetsVersion } from './contract.js';
46
49
  import { pluginsInfoLines } from './plugins-info.js';
47
50
  import { cleanRenderText, cleanScalarText } from './sanitize.js';
48
51
  /** `tui/input` return normalization: transform/handled/cancel or no opinion.
@@ -327,6 +330,72 @@ function toolResultText(event) {
327
330
  return '';
328
331
  return block.content.map(item => item.type === 'text' ? item.text : '').join('').trim();
329
332
  }
333
+ /** Phase badge for the harness goal card — mirrors the panel's PhaseBadge. */
334
+ const GOAL_RESULT_BADGE = {
335
+ active: '● active',
336
+ paused: '⏸ paused',
337
+ blocked: '⛔ blocked',
338
+ complete: '✓ complete',
339
+ };
340
+ /**
341
+ * Summary cards for the harness's goal/todo tools. Their results are machine
342
+ * JSON (`{"goal":{…}}` / `{"todos":[…]}`) that would otherwise dump under the
343
+ * tool card as a raw `⎿ {"goal":…}` line. Matched by tool-name substring AND
344
+ * payload shape, so unrelated tools and plain-text results pass through to
345
+ * the registry/raw-text path untouched. Runs on the live stream and replay.
346
+ */
347
+ function harnessToolResultView(name, data) {
348
+ const lower = name.toLowerCase();
349
+ const isGoalTool = lower.includes('goal');
350
+ const isTodoTool = lower.includes('todo');
351
+ if (!isGoalTool && !isTodoTool)
352
+ return undefined;
353
+ const block = data.message.content[0];
354
+ if (block === undefined || block.type !== 'tool-result')
355
+ return undefined;
356
+ const text = block.content.map(item => item.type === 'text' ? item.text : '').join('').trim();
357
+ if (text === '' || !text.startsWith('{'))
358
+ return undefined;
359
+ let parsed;
360
+ try {
361
+ parsed = JSON.parse(text);
362
+ }
363
+ catch {
364
+ return undefined;
365
+ }
366
+ if (typeof parsed !== 'object' || parsed === null)
367
+ return undefined;
368
+ const record = parsed;
369
+ if (isGoalTool && typeof record.goal === 'object' && record.goal !== null) {
370
+ const goal = record.goal;
371
+ if (typeof goal.objective === 'string' && typeof goal.phase === 'string') {
372
+ const badge = GOAL_RESULT_BADGE[goal.phase] ?? goal.phase;
373
+ const rounds = typeof goal.roundsStarted === 'number' && typeof goal.maxGoalRounds === 'number'
374
+ ? ` · ${goal.roundsStarted}/${goal.maxGoalRounds}`
375
+ : '';
376
+ const activation = typeof record.activation === 'string' ? ` · ${record.activation}` : '';
377
+ const lines = [`🎯 ${goal.objective}`, `${badge}${rounds}${activation}`];
378
+ const blocked = goal.blockedReason?.message;
379
+ if (typeof blocked === 'string')
380
+ lines.push(`⛔ ${blocked}`);
381
+ return { card: 'generic', content: lines.map(line => ({ type: 'text', text: line })) };
382
+ }
383
+ }
384
+ if (isTodoTool && Array.isArray(record.todos)) {
385
+ const rows = record.todos;
386
+ const done = rows.filter(row => row.status === 'completed').length;
387
+ const lines = [`todos ✓ ${done}/${rows.length}`];
388
+ for (const row of rows) {
389
+ if (row.status !== 'in_progress' || typeof row.content !== 'string')
390
+ continue;
391
+ lines.push(`● ${row.content}`);
392
+ if (lines.length >= 4)
393
+ break;
394
+ }
395
+ return { card: 'generic', content: lines.map(line => ({ type: 'text', text: line })) };
396
+ }
397
+ return undefined;
398
+ }
330
399
  function toolErrorText(event) {
331
400
  const failure = event.data.error;
332
401
  if (failure === undefined)
@@ -432,8 +501,7 @@ export function createChannel(ctx, initialAgent, options) {
432
501
  try {
433
502
  runtime.interrupt(target, { kind: 'ancestor', agent });
434
503
  subagentStore.onCancelled(agentId, 'interrupted');
435
- state.subagents = subagentStore.snapshot();
436
- syncSubagentRows();
504
+ syncSubagentsNow();
437
505
  state.emit();
438
506
  return true;
439
507
  }
@@ -466,9 +534,12 @@ export function createChannel(ctx, initialAgent, options) {
466
534
  /**
467
535
  * Sync subagentStore state into ChatRows (insert/update in state.rows).
468
536
  * Called whenever subagent state changes (spawned/completed/failed/output).
537
+ * Accepts a caller-taken snapshot to avoid the double copy on the hot path
538
+ * (session/event fires per subagent token: snapshot here + snapshot in the
539
+ * caller = two full state copies before emitStream's 16ms throttle).
469
540
  */
470
- const syncSubagentRows = () => {
471
- const snapshot = subagentStore.snapshot();
541
+ const syncSubagentRows = (preSnapshot) => {
542
+ const snapshot = preSnapshot ?? subagentStore.snapshot();
472
543
  for (const sub of snapshot) {
473
544
  let row = subagentRowsByAgentId.get(sub.agentId);
474
545
  if (!row) {
@@ -545,6 +616,32 @@ export function createChannel(ctx, initialAgent, options) {
545
616
  const listeners = new Set();
546
617
  /** True while a frame-aligned stream notification is pending (emitStream). */
547
618
  let streamNotifyScheduled = false;
619
+ /** True while subagent assistant/chunk deltas have deferred their
620
+ * snapshot+projection to the frame-aligned flush (emitStream's timer).
621
+ * Chunks arrive at token rate (100-300 events/s) and the projection is a
622
+ * full deep state copy (SubagentActivityStore.snapshot) plus a SubagentRow
623
+ * rebuild per tracked agent — running that per token sits BEFORE
624
+ * emitStream's 16ms coalescing and defeats it. Non-chunk events
625
+ * (tool/call, subagent/end, interrupt) project immediately and clear
626
+ * this flag, so lifecycle transitions stay synchronous. */
627
+ let subagentStreamDirty = false;
628
+ /** Deferred projection for the frame-aligned flush: runs INSIDE the
629
+ * emitStream timer, before listeners wake, so React always reads fully
630
+ * projected rows. No-op unless a chunk marked the projection dirty. */
631
+ const flushSubagentStream = () => {
632
+ if (!subagentStreamDirty)
633
+ return;
634
+ subagentStreamDirty = false;
635
+ state.subagents = subagentStore.snapshot();
636
+ syncSubagentRows(state.subagents);
637
+ };
638
+ /** Immediate projection; supersedes any pending deferred flush (the fresh
639
+ * snapshot already contains everything the deferred pass would project). */
640
+ const syncSubagentsNow = () => {
641
+ subagentStreamDirty = false;
642
+ state.subagents = subagentStore.snapshot();
643
+ syncSubagentRows(state.subagents);
644
+ };
548
645
  // foldRows incremental cursor (see foldRows): rows only append past the
549
646
  // fold line, so each pass touches only newly-eligible rows.
550
647
  const foldCursor = { rows: null, index: 0 };
@@ -910,13 +1007,14 @@ export function createChannel(ctx, initialAgent, options) {
910
1007
  applyEffort(found);
911
1008
  return true;
912
1009
  };
913
- /** Whether the installed command service takes composer images: rc line
914
- * gate with a structural fallback, so a failed manifest probe (bundlers,
915
- * exotic loaders) still lands on the 4-param rc.8 shape at runtime. */
1010
+ /** Whether the installed command service takes composer images: version
1011
+ * gate (composer images arrived on 0.1.0-rc.8 and every later family —
1012
+ * 0.1.1 included — keeps the 4-param shape) with a structural fallback,
1013
+ * so a failed manifest probe (bundlers, exotic loaders) still lands on
1014
+ * the 4-param rc.8 shape at runtime. */
916
1015
  const commandServiceSupportsImages = (service) => {
917
- const line = installedLineOf('@deepseek-ai/dsh-commands');
918
- if (line !== undefined)
919
- return line >= 8;
1016
+ if (installedMeetsVersion('@deepseek-ai/dsh-commands', '0.1.0-rc.8'))
1017
+ return true;
920
1018
  return typeof service.execute?.length === 'number'
921
1019
  && service.execute.length >= 4;
922
1020
  };
@@ -1129,6 +1227,74 @@ export function createChannel(ctx, initialAgent, options) {
1129
1227
  const index = deriveModeIndex(agent.session.events);
1130
1228
  await applyMode(sessionModes[(index + 1) % sessionModes.length]);
1131
1229
  };
1230
+ // Session-lifetime candidate pool for non-path queries. The load promise is
1231
+ // shared so concurrent first keystrokes cannot kick off duplicate scans, and
1232
+ // it is keyed by cwd so a /workspace switch or resumed session never reuses
1233
+ // another directory's listing.
1234
+ const fileCandidateCache = { cwd: '', load: undefined };
1235
+ // `/model <provider/id>` completion: the model catalog is async (one llm
1236
+ // listModels per provider), so the first keystrokes that could be heading
1237
+ // for /model warm a session-lifetime cache — the shared promise dedupes
1238
+ // concurrent triggers, children() synchronously serves whatever has landed,
1239
+ // and the arrival state.emit() reopens the menu mid-typing. switchModel's
1240
+ // success path drops the cache so the [current] tag re-resolves against
1241
+ // the new route.
1242
+ const modelNodeCache = {
1243
+ nodes: undefined,
1244
+ load: undefined,
1245
+ };
1246
+ const warmModelNodes = () => {
1247
+ if (modelNodeCache.load !== undefined)
1248
+ return;
1249
+ modelNodeCache.load = state.listModels().then((list) => {
1250
+ modelNodeCache.nodes = list.map((model) => ({
1251
+ name: `${model.provider}/${model.id}`,
1252
+ description: model.name,
1253
+ ...(state.provider === model.provider && state.model === model.id
1254
+ ? { tag: 'current' }
1255
+ : {}),
1256
+ }));
1257
+ state.emit();
1258
+ }).catch(() => {
1259
+ // listModels already swallows per-provider failures; this only fires
1260
+ // when the llm service shape itself is missing — settle on an empty
1261
+ // menu rather than retrying on every keystroke.
1262
+ modelNodeCache.nodes = [];
1263
+ });
1264
+ };
1265
+ // `/preset <id>` completion: same warm-cache pattern as models. The
1266
+ // current/default tags resolve at children() time (sync state reads), so
1267
+ // no cache invalidation is needed on switch.
1268
+ const presetOptionCache = {
1269
+ list: undefined,
1270
+ load: undefined,
1271
+ };
1272
+ const warmPresetOptions = () => {
1273
+ if (presetOptionCache.load !== undefined)
1274
+ return;
1275
+ presetOptionCache.load = state.listPresets().then((list) => {
1276
+ presetOptionCache.list = list;
1277
+ state.emit();
1278
+ }).catch(() => {
1279
+ presetOptionCache.list = [];
1280
+ });
1281
+ };
1282
+ // `/effort <id>` completion: state.effortLevels is the sync vocabulary
1283
+ // (populated on route changes); when still unknown, one best-effort
1284
+ // resolveEfforts warms it. `tried` caps the retry — resolveEfforts
1285
+ // notifies on hard errors, so keystroke-time retries would spam.
1286
+ const effortWarm = { tried: false };
1287
+ const warmEffortLevels = () => {
1288
+ if (state.effortLevels !== undefined || effortWarm.tried)
1289
+ return;
1290
+ effortWarm.tried = true;
1291
+ void resolveEfforts().then((resolved) => {
1292
+ if (resolved === 'unavailable' || resolved === 'error')
1293
+ return;
1294
+ effortWarm.tried = false;
1295
+ state.emit();
1296
+ }).catch(() => { });
1297
+ };
1132
1298
  const state = {
1133
1299
  effortLevels: undefined,
1134
1300
  version: 0,
@@ -1162,6 +1328,7 @@ export function createChannel(ctx, initialAgent, options) {
1162
1328
  diffLayout: options.diffLayout ?? 'auto',
1163
1329
  thinkingFold: options.thinkingFold ?? 'preview',
1164
1330
  toolBackground: normalizeToolBackground(options.toolBackground),
1331
+ scrollGutter: normalizeScrollGutter(options.scrollGutter),
1165
1332
  statusBar: normalizeStatusBar(options.statusBar),
1166
1333
  whale: options.whale !== false,
1167
1334
  minimal: options.minimal === true,
@@ -1174,7 +1341,94 @@ export function createChannel(ctx, initialAgent, options) {
1174
1341
  pending: [],
1175
1342
  commandList: LOCAL_COMMANDS,
1176
1343
  commandCompletions(input) {
1344
+ // Warm the async vocabularies as soon as the input could be heading
1345
+ // for their commands (`/m`, `/pre`, …) — by the time a trailing space
1346
+ // asks children() for nodes, the fetch has usually landed.
1347
+ const head = input.slice(1).split(/[\t ]/)[0]?.toLowerCase() ?? '';
1348
+ if (head !== '') {
1349
+ if ('model'.startsWith(head))
1350
+ warmModelNodes();
1351
+ if ('preset'.startsWith(head))
1352
+ warmPresetOptions();
1353
+ if ('effort'.startsWith(head))
1354
+ warmEffortLevels();
1355
+ }
1177
1356
  return completeCommands(input, state.commandList, (path) => {
1357
+ if (path.length === 1 && path[0] === 'model') {
1358
+ // provider/id specs, current model tagged; see modelNodeCache.
1359
+ warmModelNodes();
1360
+ return modelNodeCache.nodes ?? [];
1361
+ }
1362
+ if (path.length === 1 && path[0] === 'lang') {
1363
+ return [
1364
+ { name: 'status', description: 'Show the current UI language', descriptionKey: 'sugg-status-desc' },
1365
+ ...LANGS.map((lang) => ({
1366
+ name: lang,
1367
+ description: `Switch the UI language to ${lang}`,
1368
+ descriptionKey: lang === 'zh' ? 'sugg-lang-zh-desc' : 'sugg-lang-en-desc',
1369
+ ...(getLang() === lang ? { tag: 'current' } : {}),
1370
+ })),
1371
+ ];
1372
+ }
1373
+ if (path.length === 1 && path[0] === 'theme') {
1374
+ return [
1375
+ { name: 'status', description: 'Show the current theme', descriptionKey: 'sugg-status-desc' },
1376
+ { name: AUTO_THEME_NAME, description: 'Follow the terminal background', descriptionKey: 'sugg-theme-auto-desc' },
1377
+ ...THEME_NAMES.map((name) => ({
1378
+ name,
1379
+ description: `Built-in theme ${name}`,
1380
+ descriptionKey: 'sugg-theme-builtin-desc',
1381
+ })),
1382
+ ...listCustomThemes()
1383
+ .filter((spec) => spec.name !== AUTO_THEME_NAME)
1384
+ .map((spec) => ({
1385
+ name: spec.name,
1386
+ description: `User theme (${spec.base} base)`,
1387
+ descriptionKey: 'sugg-theme-user-desc',
1388
+ })),
1389
+ ];
1390
+ }
1391
+ if (path.length === 1 && path[0] === 'effort') {
1392
+ warmEffortLevels();
1393
+ return [
1394
+ { name: 'status', description: 'Show the current reasoning effort', descriptionKey: 'sugg-status-desc' },
1395
+ ...(state.effortLevels ?? []).map((id) => ({
1396
+ name: id,
1397
+ description: 'Reasoning effort level',
1398
+ descriptionKey: 'sugg-effort-level-desc',
1399
+ ...(state.reasoningEffort === id ? { tag: 'current' } : {}),
1400
+ })),
1401
+ ];
1402
+ }
1403
+ if (path.length === 1 && path[0] === 'preset') {
1404
+ warmPresetOptions();
1405
+ return [
1406
+ { name: 'status', description: 'Show the current agent preset', descriptionKey: 'sugg-status-desc' },
1407
+ ...(presetOptionCache.list ?? []).map((preset) => ({
1408
+ name: preset.id,
1409
+ description: preset.description ?? preset.name ?? preset.id,
1410
+ ...(preset.id === state.agentPreset
1411
+ ? { tag: 'current' }
1412
+ : preset.isDefault
1413
+ ? { tag: 'default' }
1414
+ : {}),
1415
+ })),
1416
+ ];
1417
+ }
1418
+ if (path.length === 1 && path[0] === 'activity') {
1419
+ return [
1420
+ { name: 'status', description: 'Show the current activity preset', descriptionKey: 'sugg-status-desc' },
1421
+ { name: 'frames', description: 'List or switch frame presets', descriptionKey: 'sugg-activity-frames-desc' },
1422
+ ];
1423
+ }
1424
+ if (path.length === 2 && path[0] === 'activity' && path[1] === 'frames') {
1425
+ return PRESET_NAMES.map((name) => ({
1426
+ name,
1427
+ description: 'Animation frame preset',
1428
+ descriptionKey: 'sugg-activity-frame-desc',
1429
+ ...(state.activityFrames === name ? { tag: 'current' } : {}),
1430
+ }));
1431
+ }
1178
1432
  if (path.length === 1 && path[0] === 'workspace') {
1179
1433
  const builtins = [
1180
1434
  { name: 'resume', description: 'Switch to another workspace', descriptionKey: 'cmd-desc-workspace-resume' },
@@ -1193,6 +1447,21 @@ export function createChannel(ctx, initialAgent, options) {
1193
1447
  })),
1194
1448
  ];
1195
1449
  }
1450
+ if (path.length === 1 && path[0] === 'permission') {
1451
+ // Sandbox-preset vocabulary of the `/permission` picker — Tab
1452
+ // completes it for keyboard users, Enter still dispatches.
1453
+ return [
1454
+ { name: 'read-only', description: 'Read-only session: no file writes, no commands', descriptionKey: 'permission-preset-readonly-desc' },
1455
+ { name: 'workspace-write', description: 'Read/write inside the workspace; writes need a prior read', descriptionKey: 'permission-preset-workspace-write-desc' },
1456
+ { name: 'danger-full-access', description: 'Unrestricted access, no approvals', descriptionKey: 'permission-preset-full-access-desc' },
1457
+ ];
1458
+ }
1459
+ if (path.length === 1 && path[0] === 'plan') {
1460
+ return [
1461
+ { name: 'on', description: 'Enter plan mode: read-only, plan before acting', descriptionKey: 'plan-mode-on-desc' },
1462
+ { name: 'off', description: 'Exit plan mode, back to normal execution', descriptionKey: 'plan-mode-off-desc' },
1463
+ ];
1464
+ }
1196
1465
  return commandTrees?.children(path) ?? [];
1197
1466
  });
1198
1467
  },
@@ -1233,6 +1502,9 @@ export function createChannel(ctx, initialAgent, options) {
1233
1502
  streamNotifyScheduled = true;
1234
1503
  const timer = setTimeout(() => {
1235
1504
  streamNotifyScheduled = false;
1505
+ // Deferred subagent projection rides this same frame: flush BEFORE
1506
+ // the listeners wake so React reads fully projected rows.
1507
+ flushSubagentStream();
1236
1508
  foldRows(state.rows, MAX_ROWS, foldCursor);
1237
1509
  for (const listener of listeners)
1238
1510
  listener();
@@ -2043,6 +2315,11 @@ export function createChannel(ctx, initialAgent, options) {
2043
2315
  state.agentPreset = modelComposed.agentPreset;
2044
2316
  state.model = model;
2045
2317
  state.provider = provider;
2318
+ // /model completion cache: the [current] tag was resolved at fetch
2319
+ // time — drop the cache so the next `/model ` refetches for the new
2320
+ // route.
2321
+ modelNodeCache.nodes = undefined;
2322
+ modelNodeCache.load = undefined;
2046
2323
  state.tps = undefined;
2047
2324
  state.tpsSamples = [];
2048
2325
  state.lastUsage = undefined;
@@ -2153,6 +2430,13 @@ export function createChannel(ctx, initialAgent, options) {
2153
2430
  state.toolBackground = normalized;
2154
2431
  state.emit();
2155
2432
  },
2433
+ setScrollGutter(mode) {
2434
+ const normalized = normalizeScrollGutter(mode);
2435
+ if (normalized === state.scrollGutter)
2436
+ return;
2437
+ state.scrollGutter = normalized;
2438
+ state.emit();
2439
+ },
2156
2440
  setStatusBar(config) {
2157
2441
  const next = normalizeStatusBar({ ...state.statusBar, ...config });
2158
2442
  const changed = Object.keys(next).some(key => next[key] !== state.statusBar[key]);
@@ -2460,9 +2744,33 @@ export function createChannel(ctx, initialAgent, options) {
2460
2744
  signal: options?.signal,
2461
2745
  });
2462
2746
  },
2463
- listFiles() {
2747
+ async listFileCandidates(query, options) {
2464
2748
  const fs = ctx.get('fs');
2465
- return listFilesDeep(fs, state.cwd);
2749
+ if (!fs || options?.signal?.aborted)
2750
+ return [];
2751
+ if (isPathLikeQuery(query)) {
2752
+ return listPathCandidates(fs, state.cwd, query, options?.signal, options?.topK ?? 50);
2753
+ }
2754
+ if (fileCandidateCache.cwd !== state.cwd) {
2755
+ fileCandidateCache.cwd = state.cwd;
2756
+ fileCandidateCache.load = undefined;
2757
+ }
2758
+ fileCandidateCache.load ??= listFilesDeepCandidates(fs, state.cwd).then(candidates => {
2759
+ if (candidates.length > 0)
2760
+ return candidates;
2761
+ // An empty scan is not worth caching forever — retry on next query.
2762
+ fileCandidateCache.load = undefined;
2763
+ return candidates;
2764
+ });
2765
+ const candidates = await fileCandidateCache.load;
2766
+ if (options?.signal?.aborted)
2767
+ return [];
2768
+ return rankFileCandidates(candidates, query, options?.topK ?? 50);
2769
+ },
2770
+ async listFiles() {
2771
+ const fs = ctx.get('fs');
2772
+ const candidates = await listFilesDeepCandidates(fs, state.cwd);
2773
+ return candidates.map(candidate => candidate.path);
2466
2774
  },
2467
2775
  async listSessions() {
2468
2776
  // Every stored session, classified and unfiltered. Which of them a
@@ -3310,6 +3618,12 @@ ${output}
3310
3618
  * its result-time contextual diff back from here). */
3311
3619
  const presentResultView = (name, rawArgs, data) => {
3312
3620
  try {
3621
+ // Harness goal/todo tools first: their raw JSON reads as noise in the
3622
+ // transcript — fold recognizable shapes into a summary card before the
3623
+ // registry gets a chance to (not) know them.
3624
+ const local = harnessToolResultView(name, data);
3625
+ if (local !== undefined)
3626
+ return local;
3313
3627
  const tool = toolsRegistry?.get(name, agent);
3314
3628
  if (tool?.presentResult === undefined)
3315
3629
  return undefined;
@@ -3442,9 +3756,21 @@ ${output}
3442
3756
  state.spinnerMode = 'requesting';
3443
3757
  }
3444
3758
  };
3759
+ /** Fold one goal mutation into the channel's goal projection. */
3760
+ const applyGoalChange = (change) => {
3761
+ if (change.operation === 'clear') {
3762
+ state.goal = undefined;
3763
+ }
3764
+ else if (change.goal !== undefined) {
3765
+ state.goal = {
3766
+ ...change.goal,
3767
+ roundsStarted: change.roundsStarted ?? state.goal?.roundsStarted ?? 0,
3768
+ };
3769
+ }
3770
+ };
3445
3771
  /**
3446
3772
  * Fold one goal-sourced message into the channel's goal projection.
3447
- * Round-zero goal messages carry the full durable snapshot (or a clear
3773
+ * Round-zero goal messages may carry the full durable snapshot (or a clear
3448
3774
  * tombstone) in their source; positive-round messages are admitted
3449
3775
  * continuation prompts that only advance the rounds counter.
3450
3776
  */
@@ -3464,15 +3790,7 @@ ${output}
3464
3790
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may not match the static type
3465
3791
  if (change === undefined || change.kind !== 'goal/change')
3466
3792
  return;
3467
- if (change.operation === 'clear') {
3468
- state.goal = undefined;
3469
- }
3470
- else if (change.goal !== undefined) {
3471
- state.goal = {
3472
- ...change.goal,
3473
- roundsStarted: change.roundsStarted ?? state.goal?.roundsStarted ?? 0,
3474
- };
3475
- }
3793
+ applyGoalChange(change);
3476
3794
  };
3477
3795
  /** True while the durable transcript is being replayed (boot /resume /
3478
3796
  * rewind / model-switch fork). The assistant/message reasoning-rebuild
@@ -3500,6 +3818,15 @@ ${output}
3500
3818
  }
3501
3819
  };
3502
3820
  const renderEvent = (event) => {
3821
+ // Top-level `goal/change` events are how the goal service actually
3822
+ // records durable goal mutations (create/edit/pause/resume/complete/
3823
+ // block/clear) — confirmed in production logs. The pinned peer's
3824
+ // SessionEvent union predates the type, so admit it structurally: the
3825
+ // goal chip and panel stay dark without this fold.
3826
+ if (event.type === 'goal/change') {
3827
+ applyGoalChange(event.data);
3828
+ return;
3829
+ }
3503
3830
  switch (event.type) {
3504
3831
  case 'user/message': {
3505
3832
  // Compaction checkpoint: `source = { kind: 'plugin', plugin:
@@ -3542,10 +3869,13 @@ ${output}
3542
3869
  contextWarned = false;
3543
3870
  break;
3544
3871
  }
3545
- // Same-session goal domain: round-zero goal-sourced messages carry
3546
- // the durable goal snapshot (or clear tombstone) in their source.
3547
- // They are not transcript bubbles — they drive the goal panel's
3548
- // live projection (replayed on resume/rewind like every other event).
3872
+ // Same-session goal domain: goal-sourced messages are the round
3873
+ // driver's continuation prompts (positive rounds advance the
3874
+ // counter); some hosts also inline the durable snapshot in a
3875
+ // round-zero source. They are not transcript bubbles — they drive
3876
+ // the goal panel's live projection (replayed on resume/rewind like
3877
+ // every other event; the snapshot itself arrives as the top-level
3878
+ // `goal/change` event admitted above).
3549
3879
  if (event.data.source.kind === 'goal') {
3550
3880
  applyGoalEvent(event);
3551
3881
  break;
@@ -4127,12 +4457,18 @@ ${output}
4127
4457
  const subagentId = subagentStore.getSubagentIdBySession(session);
4128
4458
  if (subagentId) {
4129
4459
  subagentStore.onSessionEvent(subagentId, event);
4130
- state.subagents = subagentStore.snapshot();
4131
- syncSubagentRows();
4132
- if (event.type === 'assistant/chunk')
4460
+ if (event.type === 'assistant/chunk') {
4461
+ // Token-rate path (100-300 events/s): the store append stays
4462
+ // synchronous (cheap); the expensive snapshot + row projection
4463
+ // defers to the frame-aligned flush inside emitStream's 16ms
4464
+ // timer, so it coalesces exactly like the main-agent stream.
4465
+ subagentStreamDirty = true;
4133
4466
  state.emitStream();
4134
- else
4467
+ }
4468
+ else {
4469
+ syncSubagentsNow();
4135
4470
  state.emit();
4471
+ }
4136
4472
  return;
4137
4473
  }
4138
4474
  // Otherwise handle main agent session
@@ -4195,8 +4531,7 @@ ${output}
4195
4531
  catch {
4196
4532
  // Session discovery is best-effort and must not break the parent turn.
4197
4533
  }
4198
- state.subagents = subagentStore.snapshot();
4199
- syncSubagentRows();
4534
+ syncSubagentsNow();
4200
4535
  state.emit();
4201
4536
  });
4202
4537
  const disposeEnd = ctx.on('subagent/end', (info) => {
@@ -4218,8 +4553,7 @@ ${output}
4218
4553
  subagentStore.onCancelled(info.id, info.stopReason, output);
4219
4554
  else
4220
4555
  subagentStore.onFailed(info.id, info.stopReason || 'Unknown error');
4221
- state.subagents = subagentStore.snapshot();
4222
- syncSubagentRows();
4556
+ syncSubagentsNow();
4223
4557
  state.emit();
4224
4558
  });
4225
4559
  return () => {
@@ -4373,33 +4707,64 @@ function usageOutputTokens(usage) {
4373
4707
  ? value
4374
4708
  : undefined;
4375
4709
  }
4376
- /**
4377
- * `@` file listing through the leaf's fs service (dsh-fs-local): skips
4378
- * VCS/dependency/build dirs and rotates across directory listings so one
4379
- * large subtree cannot consume the global MAX_FILES budget. That budget also
4380
- * bounds directory reads without imposing an arbitrary source-tree depth.
4381
- * Relative directories retain the trailing `/` that FileSuggestions expects.
4382
- * Unreadable subtrees are skipped, not fatal.
4383
- */
4384
- async function listFilesDeep(fs, root) {
4710
+ async function listPathCandidates(fs, cwd, query, signal, topK) {
4711
+ const normalized = query.replaceAll('\\', '/');
4712
+ const slash = normalized.lastIndexOf('/');
4713
+ // `.` / `..` without a trailing separator are whole-directory queries too.
4714
+ const bareDir = slash < 0 && (normalized === '.' || normalized === '..' || normalized === '~');
4715
+ const directoryPart = slash < 0 ? (bareDir ? `${normalized}/` : '') : normalized.slice(0, slash + 1);
4716
+ const nameQuery = slash < 0 || bareDir ? '' : normalized.slice(slash + 1);
4717
+ // `~/` expands against the host home (matches the cwd resolution rules);
4718
+ // drive-letter and POSIX-absolute prefixes pass through untouched.
4719
+ const expanded = directoryPart === '~/'
4720
+ ? `${homeDir()}/`
4721
+ : directoryPart.startsWith('/') || /^[A-Za-z]:\//.test(directoryPart)
4722
+ ? directoryPart
4723
+ : join(cwd, directoryPart || '.');
4724
+ try {
4725
+ if (signal?.aborted)
4726
+ return [];
4727
+ const target = await fs.resolve(expanded);
4728
+ const entries = (await fs.listDir(target)).slice().sort((a, b) => a.name.localeCompare(b.name));
4729
+ return rankFileCandidates(entries.filter(entry => entry.type === 'file' || entry.type === 'directory').map(entry => {
4730
+ const path = `${directoryPart}${entry.name}${entry.type === 'directory' ? '/' : ''}`;
4731
+ return { id: path, path, displayPath: path, name: entry.name, kind: entry.type, score: 0 };
4732
+ }), nameQuery, topK);
4733
+ }
4734
+ catch {
4735
+ return [];
4736
+ }
4737
+ }
4738
+ async function listFilesDeepCandidates(fs, root, signal) {
4385
4739
  if (!fs)
4386
4740
  return [];
4387
4741
  const out = [];
4388
4742
  const SKIP = new Set(['node_modules', '.git', '.hg', '.svn', '.DS_Store', 'dist']);
4389
4743
  const BUILD_DIR = /^(?:build(?:[-_].*)?|cmake-build(?:[-_].*)?)$/i;
4390
- const MAX_FILES = 100;
4391
- const directories = [{ dir: root, prefix: '', index: 0 }];
4392
- while (directories.length > 0 && out.length < MAX_FILES) {
4393
- const current = directories.shift();
4394
- if (!current)
4395
- continue;
4744
+ const queue = [{ dir: root, prefix: '', index: 0 }];
4745
+ const visited = new Set();
4746
+ const maxFiles = 100;
4747
+ const maxDirectories = 100;
4748
+ let fileCount = 0;
4749
+ let dirCount = 0;
4750
+ // Round-robin: each directory yields ONE non-skipped entry per visit before
4751
+ // it re-queues, so a large early sibling (e.g. `generated/` with 120 files)
4752
+ // cannot starve `src/` out of the per-kind budgets. This is the regression
4753
+ // contract pinned by scripts/verify-file-completion.mjs.
4754
+ while (queue.length && fileCount < maxFiles && dirCount < maxDirectories) {
4755
+ if (signal?.aborted)
4756
+ return [];
4757
+ const current = queue.shift();
4396
4758
  if (!current.entries) {
4397
4759
  try {
4398
4760
  const target = await fs.resolve(current.dir);
4399
- current.entries = await fs.listDir(target);
4761
+ if (visited.has(target.displayPath))
4762
+ continue;
4763
+ visited.add(target.displayPath);
4764
+ current.entries = (await fs.listDir(target)).slice().sort((a, b) => a.name.localeCompare(b.name));
4400
4765
  }
4401
4766
  catch {
4402
- continue; // unreadable subtree — skip
4767
+ continue;
4403
4768
  }
4404
4769
  }
4405
4770
  let entry;
@@ -4413,22 +4778,23 @@ async function listFilesDeep(fs, root) {
4413
4778
  if (!entry)
4414
4779
  continue;
4415
4780
  if (current.index < current.entries.length)
4416
- directories.push(current);
4417
- const rel = current.prefix ? `${current.prefix}/${entry.name}` : entry.name;
4781
+ queue.push(current);
4782
+ const path = current.prefix ? `${current.prefix}/${entry.name}` : entry.name;
4418
4783
  if (entry.type === 'directory') {
4419
- out.push(`${rel}/`);
4420
- directories.push({
4421
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: symlink targets optional
4422
- dir: entry.target?.displayPath ?? join(current.dir, entry.name),
4423
- prefix: rel,
4424
- index: 0,
4425
- });
4784
+ if (dirCount >= maxDirectories)
4785
+ continue;
4786
+ out.push({ id: `${path}/`, path: `${path}/`, displayPath: `${path}/`, name: entry.name, kind: 'directory', score: 0 });
4787
+ dirCount += 1;
4788
+ queue.push({ dir: entry.target?.displayPath ?? join(current.dir, entry.name), prefix: path, index: 0 });
4426
4789
  }
4427
4790
  else if (entry.type === 'file') {
4428
- out.push(rel);
4791
+ if (fileCount >= maxFiles)
4792
+ continue;
4793
+ out.push({ id: path, path, displayPath: path, name: entry.name, kind: 'file', score: 0 });
4794
+ fileCount += 1;
4429
4795
  }
4430
4796
  }
4431
- return out;
4797
+ return out.sort((a, b) => a.path.localeCompare(b.path));
4432
4798
  }
4433
4799
  /** One attached file's contribution is capped so an absent-minded `@` of a
4434
4800
  * huge file cannot blow the context window (CC caps @-attachments too). */