@agi-fans/dsh-tui 0.18.0 → 0.19.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 (68) hide show
  1. package/lib/CHANGELOG.md +31 -0
  2. package/lib/chrome/box.d.ts +2 -0
  3. package/lib/chrome/box.js +5 -1
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/main-screen-renderer.d.ts +61 -11
  6. package/lib/chrome/main-screen-renderer.js +1674 -71
  7. package/lib/chrome/main-screen-renderer.js.map +1 -1
  8. package/lib/chrome/markdown.js +21 -13
  9. package/lib/chrome/markdown.js.map +1 -1
  10. package/lib/chrome/renderer.d.ts +75 -0
  11. package/lib/chrome/renderer.js.map +1 -1
  12. package/lib/chrome/theme.d.ts +18 -0
  13. package/lib/chrome/theme.js +18 -0
  14. package/lib/chrome/theme.js.map +1 -1
  15. package/lib/chrome/tool-args.d.ts +24 -1
  16. package/lib/chrome/tool-args.js +40 -1
  17. package/lib/chrome/tool-args.js.map +1 -1
  18. package/lib/chrome/tool-renderers.d.ts +11 -0
  19. package/lib/chrome/tool-renderers.js +55 -5
  20. package/lib/chrome/tool-renderers.js.map +1 -1
  21. package/lib/chrome/welcome-tips.js +1 -1
  22. package/lib/chrome/welcome-tips.js.map +1 -1
  23. package/lib/definition.d.ts +59 -7
  24. package/lib/definition.js.map +1 -1
  25. package/lib/input/keybindings-config.d.ts +1 -1
  26. package/lib/input/keybindings-config.js +2 -1
  27. package/lib/input/keybindings-config.js.map +1 -1
  28. package/lib/input/keys.d.ts +7 -0
  29. package/lib/input/keys.js +11 -4
  30. package/lib/input/keys.js.map +1 -1
  31. package/lib/runtime/provider-local.d.ts +20 -4
  32. package/lib/runtime/provider-local.js +435 -63
  33. package/lib/runtime/provider-local.js.map +1 -1
  34. package/lib/runtime/tool-presentation.js +43 -30
  35. package/lib/runtime/tool-presentation.js.map +1 -1
  36. package/lib/session/feature-toggles.d.ts +100 -0
  37. package/lib/session/feature-toggles.js +170 -0
  38. package/lib/session/feature-toggles.js.map +1 -0
  39. package/lib/session/fold-policy.d.ts +21 -0
  40. package/lib/session/fold-policy.js +15 -0
  41. package/lib/session/fold-policy.js.map +1 -0
  42. package/lib/session/job-notice.d.ts +4 -0
  43. package/lib/session/job-notice.js +44 -3
  44. package/lib/session/job-notice.js.map +1 -1
  45. package/lib/session/session-controller.js +51 -13
  46. package/lib/session/session-controller.js.map +1 -1
  47. package/lib/session/tui-settings.d.ts +6 -1
  48. package/lib/session/tui-settings.js +3 -1
  49. package/lib/session/tui-settings.js.map +1 -1
  50. package/lib/views/event-views.d.ts +26 -2
  51. package/lib/views/event-views.js +160 -43
  52. package/lib/views/event-views.js.map +1 -1
  53. package/lib/views/hotkeys.d.ts +2 -0
  54. package/lib/views/hotkeys.js +7 -3
  55. package/lib/views/hotkeys.js.map +1 -1
  56. package/lib/views/settings-list.d.ts +10 -4
  57. package/lib/views/settings-list.js +41 -18
  58. package/lib/views/settings-list.js.map +1 -1
  59. package/lib/views/transcript-render.d.ts +133 -18
  60. package/lib/views/transcript-render.js +1179 -112
  61. package/lib/views/transcript-render.js.map +1 -1
  62. package/lib/views/transcript-types.d.ts +79 -1
  63. package/lib/views/transcript-types.js +11 -0
  64. package/lib/views/transcript-types.js.map +1 -1
  65. package/lib/views/workspace-changes.d.ts +27 -0
  66. package/lib/views/workspace-changes.js +128 -0
  67. package/lib/views/workspace-changes.js.map +1 -0
  68. package/package.json +36 -35
@@ -13,10 +13,14 @@
13
13
  import { TerminalNotificationController, terminalNotificationSequence, terminalProgressSequence, } from "./terminal-notifications.js";
14
14
  import { HerdrAgentReporter } from "./herdr-agent.js";
15
15
  import { ComposerImages } from "./composer-images.js";
16
+ import { mergeJobSettlement } from "../session/job-notice.js";
16
17
  import { PlainTui } from "./plain-tui.js";
17
18
  import { homedir } from 'node:os';
18
19
  import { join, sep } from 'node:path';
19
20
  import { StringDecoder } from 'node:string_decoder';
21
+ import { defaultFeatureStates, featurePatchPath, featureStatesFromPatch, renderFeatureBlock, replaceFeatureBlock, } from "../session/feature-toggles.js";
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
20
24
  import { TUI_SERVICE, } from "../definition.js";
21
25
  import { applySlashCompletion, formatHelpText, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
22
26
  import { applyPathCompletion, defaultPathSource, editorLineAt, findPathToken, parsePathPrefix, pathSuggestions, searchPathSuggestions, } from "../views/path-complete.js";
@@ -30,7 +34,7 @@ import { InputEditor, lineEnd, lineStart } from "../input/editor.js";
30
34
  import { applySettingsEvent, createSettings, } from "../views/settings-list.js";
31
35
  import { appendTrajectoryEvent, applyTrajectoryEvent, createTrajectory, trajectoryDetailMetrics, trajectoryListMetrics, } from "../views/trajectory.js";
32
36
  import { applyAgentHubEvent, createAgentHub, updateAgentHub, } from "../views/agent-hub.js";
33
- import { applyEvent, applyStreamChunk, blockSearchText, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, } from "../views/event-views.js";
37
+ import { applyEvent, applyStreamChunk, blockSearchText, initialTranscript, replayEvents, renderView, turnGroups, transcriptSeam, TRANSCRIPT_FAST_SCROLL, withWorkspaceSummary, } from "../views/event-views.js";
34
38
  import { applyTranscriptSearchEvent, createTranscriptSearch, searchBlockIndexes, } from "../views/transcript-search.js";
35
39
  import { nextRevealStep, revealStreamingAssistant, streamingAssistantKey, streamingAssistantUnits, } from "../views/streaming-reveal.js";
36
40
  import { flushPending, parseKeys } from "../input/keys.js";
@@ -38,6 +42,7 @@ import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
38
42
  import { colorDisabledByEnv, detectTrueColor } from "../chrome/theme.js";
39
43
  import { renderTool } from "../chrome/tool-renderers.js";
40
44
  import { TUI_SETTINGS_ENTRY, TUI_SETTINGS_FIELDS } from "../session/tui-settings.js";
45
+ import { DEFAULT_FOLD_DENSITY, foldPolicy, resolveFoldDensity, } from "../session/fold-policy.js";
41
46
  import { defaultStatusBarConfig, resolveStatusBarConfig } from "../chrome/status-config.js";
42
47
  import { HistoryStore } from "../session/history-store.js";
43
48
  import { loadKeybindings } from "../input/keybindings-config.js";
@@ -46,7 +51,7 @@ import z from '@deepseek-ai/schemastery';
46
51
  import { movePromptSelection, filteredPromptOptions, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, } from "../views/prompt-selector.js";
47
52
  import { refreshProjectContext, resolveProjectContext } from "../session/project-context.js";
48
53
  import { pickWelcomeTips } from "../chrome/welcome-tips.js";
49
- import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount } from "../views/hotkeys.js";
54
+ import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount, keysForAction } from "../views/hotkeys.js";
50
55
  import { imagePathCandidates, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
51
56
  import { APP_NAME, APP_VERSION } from "../session/package-metadata.js";
52
57
  const DOUBLE_CTRL_C_MS = 500;
@@ -99,6 +104,7 @@ export const Config = z.object({
99
104
  model: z.string(),
100
105
  historyPath: z.string(),
101
106
  keybindingsPath: z.string(),
107
+ dshHome: z.string(),
102
108
  ...TUI_SETTINGS_FIELDS,
103
109
  });
104
110
  /**
@@ -122,6 +128,25 @@ function resolveColors(preference, isTty) {
122
128
  return preference;
123
129
  return isTty && !colorDisabledByEnv();
124
130
  }
131
+ /**
132
+ * The first body row whose shape changed between two frames: the earliest
133
+ * surface that opened, closed, or changed how it is open. Rows above it are
134
+ * the same in both frames, which is what lets the renderer keep history that
135
+ * is still true and repair only what moved.
136
+ */
137
+ function firstReshapedRow(before, after) {
138
+ const keys = (marks) => new Set(marks.map(mark => mark.key));
139
+ const was = keys(before);
140
+ const now = keys(after);
141
+ let row = Number.POSITIVE_INFINITY;
142
+ for (const mark of before)
143
+ if (!now.has(mark.key))
144
+ row = Math.min(row, mark.row);
145
+ for (const mark of after)
146
+ if (!was.has(mark.key))
147
+ row = Math.min(row, mark.row);
148
+ return Number.isFinite(row) ? row : 0;
149
+ }
125
150
  export class LocalTui {
126
151
  #deferInitialRender = false;
127
152
  #terminalProfile;
@@ -145,6 +170,10 @@ export class LocalTui {
145
170
  #search = null;
146
171
  #transcriptSearch = null;
147
172
  #settings = null;
173
+ /** Optional-feature state read from the Profile patch this process booted with. */
174
+ #features;
175
+ /** Profile patch the Features section writes; absent hides that section. */
176
+ #featurePatch;
148
177
  #copySelector = null;
149
178
  #trajectory = null;
150
179
  #agentHub = null;
@@ -191,9 +220,16 @@ export class LocalTui {
191
220
  #scrollStart = 0;
192
221
  #maxStart = 0;
193
222
  #scrollBudget = 0;
223
+ #blockStarts = [];
224
+ /** Last frame's {@link TranscriptScroll.foldShape}, to notice a fold change. */
225
+ #foldShape;
226
+ /** Last frame's open surfaces, to find where a fold change starts. */
227
+ #foldMarks;
194
228
  #follow = true;
195
229
  #focusBlock;
196
- #expandTools = false;
230
+ #focusBlockEdge;
231
+ #foldDensity = DEFAULT_FOLD_DENSITY;
232
+ #foldPolicy = foldPolicy(DEFAULT_FOLD_DENSITY);
197
233
  #motion = 'full';
198
234
  #terminalProgress = false;
199
235
  #terminalProgressActive = false;
@@ -211,7 +247,24 @@ export class LocalTui {
211
247
  #notificationThreshold = '30s';
212
248
  #statusBar = defaultStatusBarConfig();
213
249
  #toolsExpanded = false;
214
- #expandedToolCalls = new Set();
250
+ /**
251
+ * The reader opened something with Ctrl+O or Alt+O and is reading it.
252
+ *
253
+ * Openings are shown in a browsing frame, never in the flow that follows the
254
+ * tail: every opening starts an inspection, and returning to the tail ends it
255
+ * and closes them all. The flow's rows cross into the terminal's history, which can only be
256
+ * appended to: opening a run in place pushed rows that closing it could not
257
+ * take back, so the screen either went blank where they had been or painted
258
+ * rows history already held, and the transcript read twice. A browsing frame
259
+ * repaints the screen and nothing above it, so reading an opened run leaves
260
+ * the flow — and the history — exactly as it was.
261
+ */
262
+ #inspecting = false;
263
+ #mouseTracking = false;
264
+ #jumpToLatest;
265
+ #openedGroups = new Set();
266
+ /** Turn scopes whose tools stay detailed as new calls arrive during inspection. */
267
+ #detailedTurns = new Set();
215
268
  #tools = [];
216
269
  #runtimeCommands = [];
217
270
  #prompt = null;
@@ -281,6 +334,17 @@ export class LocalTui {
281
334
  this.#historyStore = paths.historyPath === undefined ? undefined : new HistoryStore(paths.historyPath);
282
335
  this.#history = this.#historyStore?.load() ?? [];
283
336
  this.#keybindings = loadKeybindings(paths.keybindingsPath);
337
+ this.#featurePatch = paths.featurePatch;
338
+ if (paths.featurePatch !== undefined) {
339
+ try {
340
+ this.#features = featureStatesFromPatch(readFileSync(paths.featurePatch, 'utf8'));
341
+ }
342
+ catch {
343
+ // A patch that cannot be read still leaves the shipped defaults; the
344
+ // write path below reports the failure when the user toggles.
345
+ this.#features = defaultFeatureStates();
346
+ }
347
+ }
284
348
  const fallback = defaultPathSource();
285
349
  this.#cwd = paths.cwd ?? fallback.cwd;
286
350
  this.#readImagePath = paths.readImagePath ?? (path => readImageFile(path, this.#cwd));
@@ -312,7 +376,6 @@ export class LocalTui {
312
376
  width: this.#term.width(),
313
377
  height: this.#term.height(),
314
378
  synchronized: this.#tty,
315
- clearScrollback: this.#terminalProfile === 'direct',
316
379
  alternateScreenOverlays: paths.alternateScreenOverlays === true,
317
380
  });
318
381
  if (this.#tty) {
@@ -352,8 +415,15 @@ export class LocalTui {
352
415
  if (this.#tty)
353
416
  this.#render();
354
417
  }
355
- event(event, presentation) {
356
- this.#state = applyEvent(this.#state, event, presentation);
418
+ event(event, presentation, workspace) {
419
+ // Paint any coalesced results and remaining streamed text before the turn's
420
+ // end changes the live work into its folded summary.
421
+ if (event.type === 'turn/end' && this.#tty && this.#state.status !== 'idle'
422
+ && turnGroups(this.#state).some(group => group.live)) {
423
+ this.#syncStreamingReveal(undefined);
424
+ this.#render();
425
+ }
426
+ this.#state = applyEvent(this.#state, event, presentation, workspace);
357
427
  this.#emitNotification(this.#notifications.event({
358
428
  type: event.type,
359
429
  time: event.time,
@@ -380,6 +450,16 @@ export class LocalTui {
380
450
  this.#plain.print();
381
451
  }
382
452
  }
453
+ setWorkspaceSummary(turn, summary) {
454
+ const next = withWorkspaceSummary(this.#state, turn, summary);
455
+ if (next === this.#state)
456
+ return;
457
+ this.#state = next;
458
+ if (this.#tty)
459
+ this.#render();
460
+ else
461
+ this.#plain.print();
462
+ }
383
463
  /**
384
464
  * Fold one live `agent/assistant-stream` chunk into the transcript and
385
465
  * schedule the streaming reveal. Durable settlement follows on the session
@@ -544,20 +624,41 @@ export class LocalTui {
544
624
  .join(' · ');
545
625
  }
546
626
  notice(text, options = {}) {
627
+ const merged = options.job === undefined ? undefined : mergeJobSettlement(this.#state, options.job);
628
+ if (merged !== undefined && this.#tty) {
629
+ const origin = merged.blocks.find((block, index) => block !== this.#state.blocks[index]);
630
+ this.#state = merged;
631
+ // A previous turn may already be in native history. Keep its new outcome
632
+ // visible at the tail as well as updating the live transcript's tool.
633
+ if (origin?.kind === 'tool' && (origin.turn === undefined || origin.turn === this.#state.turn)) {
634
+ if (options.focus === true)
635
+ this.#followTail();
636
+ this.#render();
637
+ return;
638
+ }
639
+ }
640
+ const running = options.process === true && this.#state.status !== 'idle' && this.#state.turn > 0;
547
641
  const block = {
548
642
  kind: 'notice',
549
643
  level: options.level ?? 'info',
550
644
  text,
551
645
  ...(options.framed === true ? { framed: true } : {}),
646
+ ...(running
647
+ ? { process: { turn: this.#state.turn, ...(options.processSource === undefined ? {} : { source: options.processSource }) } }
648
+ : {}),
552
649
  };
553
650
  this.#state = { ...this.#state, blocks: [...this.#state.blocks, block] };
651
+ if (options.focus === true)
652
+ this.#followTail();
554
653
  if (this.#tty)
555
654
  this.#render();
556
655
  else
557
656
  this.#plain.print();
558
657
  }
559
- commandOutput(command, text) {
658
+ commandOutput(command, text, options = {}) {
560
659
  this.#state = { ...this.#state, blocks: [...this.#state.blocks, { kind: 'commandOutput', command, text }] };
660
+ if (options.focus === true)
661
+ this.#followTail();
561
662
  if (this.#tty)
562
663
  this.#render();
563
664
  else
@@ -602,14 +703,18 @@ export class LocalTui {
602
703
  }
603
704
  });
604
705
  }
605
- replaceSession(events, presentations, status = 'idle') {
706
+ replaceSession(events, presentations, status = 'idle', reason = 'open') {
606
707
  this.#trajectory = null;
607
708
  const state = replayEvents(events, presentations);
608
- this.#state = { ...state, status, compactCommandId: undefined };
709
+ // Notices and tool catalogs do not turn a blank session into a document
710
+ // that /new needs to separate. Refreshes are the same document.
711
+ const boundary = reason === 'open' || (reason === 'new' && this.#state.blocks.some(block => block.kind !== 'notice' && block.kind !== 'toolCatalog'));
712
+ const blocks = boundary ? [transcriptSeam('session-opened'), ...state.blocks] : state.blocks;
713
+ this.#state = { ...state, status, compactCommandId: undefined, blocks };
609
714
  this.#plain.resetPrinted();
610
715
  this.#followTail();
611
716
  this.#deferInitialRender = false;
612
- this.#renderer.startEpoch({ replay: status === 'idle' ? 'full' : 'pinned' });
717
+ this.#renderer.startEpoch({ replay: reason === 'refresh' ? 'viewport' : status === 'idle' ? 'full' : 'pinned' });
613
718
  if (this.#tty)
614
719
  this.#render();
615
720
  else
@@ -672,7 +777,7 @@ export class LocalTui {
672
777
  colors: this.#colors,
673
778
  motion: this.#motion,
674
779
  terminalProgress: this.#terminalProgress,
675
- expandTools: this.#expandTools,
780
+ foldDensity: this.#foldDensity,
676
781
  checkUpdates: this.#checkUpdates,
677
782
  startupChangelog: this.#startupChangelog,
678
783
  notifications: this.#notificationPolicy,
@@ -710,7 +815,7 @@ export class LocalTui {
710
815
  this.#agentBehavior = undefined;
711
816
  this.#agentBehaviorPending = false;
712
817
  if (this.#settings !== null)
713
- this.#settings = createSettings(this.prefs());
818
+ this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features);
714
819
  if (this.#tty && !this.#disposed)
715
820
  this.#render();
716
821
  };
@@ -839,6 +944,7 @@ export class LocalTui {
839
944
  if (this.#tty) {
840
945
  this.#offData?.();
841
946
  this.#offResize?.();
947
+ this.#setMouseTracking(false);
842
948
  this.#term.input.setRawMode?.(false);
843
949
  // Leave the cursor on a fresh line below the last frame so the shell
844
950
  // prompt does not overwrite the transcript. Disable bracketed paste
@@ -1024,7 +1130,7 @@ export class LocalTui {
1024
1130
  trueColor: this.#trueColor,
1025
1131
  themeName: this.#themeName,
1026
1132
  scrollStart: this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart,
1027
- ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock }),
1133
+ ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock, focusBlockEdge: this.#focusBlockEdge ?? 'start' }),
1028
1134
  ...(this.#transcriptSearch === null ? {} : {
1029
1135
  transcriptSearch: {
1030
1136
  query: this.#transcriptSearch.query,
@@ -1034,7 +1140,10 @@ export class LocalTui {
1034
1140
  },
1035
1141
  }),
1036
1142
  toolsExpanded: this.#toolsExpanded,
1037
- expandedTools: this.#expandedToolCalls,
1143
+ expandedTools: this.#detailedToolIds(),
1144
+ toolDetailsKey: keysForAction(this.#keybindings, 'toggle-tool-details'),
1145
+ fold: this.#foldPolicy,
1146
+ openedGroups: this.#openedGroups,
1038
1147
  commands: this.#commands(),
1039
1148
  recentSessions: this.#recentSessions,
1040
1149
  welcomeTips: this.#welcomeTips,
@@ -1058,8 +1167,23 @@ export class LocalTui {
1058
1167
  })
1059
1168
  : { lines: [] };
1060
1169
  this.#focusBlock = undefined;
1170
+ this.#focusBlockEdge = undefined;
1061
1171
  this.#promptDocument = frame.promptDocument;
1062
1172
  this.#syncScroll(frame.transcript);
1173
+ this.#jumpToLatest = frame.jumpToLatest;
1174
+ this.#setMouseTracking(frame.transcript !== undefined && frame.transientSurface !== 'overlay'
1175
+ && (this.#inspecting || this.#maxStart > 0));
1176
+ // A run opening or folding reshapes rows above the screen in place; the
1177
+ // renderer's frozen boundary is a row index and has to be told, or a fold
1178
+ // blanks the screen and a large one replays the transcript into history.
1179
+ const scroll = frame.transcript;
1180
+ if (scroll?.foldShape !== undefined && scroll.foldMarks !== undefined) {
1181
+ if (this.#foldMarks !== undefined && scroll.foldShape !== this.#foldShape) {
1182
+ this.#renderer.reflow((scroll.bodyRow ?? 0) + firstReshapedRow(this.#foldMarks, scroll.foldMarks));
1183
+ }
1184
+ this.#foldShape = scroll.foldShape;
1185
+ this.#foldMarks = scroll.foldMarks;
1186
+ }
1063
1187
  this.#renderer.render(frame);
1064
1188
  }
1065
1189
  #scheduleStreamRender() {
@@ -1073,16 +1197,17 @@ export class LocalTui {
1073
1197
  }
1074
1198
  #syncScroll(scroll) {
1075
1199
  if (scroll === undefined) {
1076
- this.#scrollStart = 0;
1077
- this.#maxStart = 0;
1078
- this.#scrollBudget = 0;
1079
- this.#follow = true;
1200
+ // Full-screen surfaces temporarily cover the transcript. They carry no
1201
+ // scroll projection and must not replace the reader's return position.
1080
1202
  return;
1081
1203
  }
1082
1204
  this.#scrollStart = scroll.start;
1083
1205
  this.#maxStart = scroll.maxStart;
1084
1206
  this.#scrollBudget = scroll.budget;
1085
- if (this.#follow || this.#scrollStart >= this.#maxStart) {
1207
+ this.#blockStarts = scroll.blockStarts?.map(row => row + (scroll.bodyRow ?? 0)) ?? [];
1208
+ // An inspection stays a browsing frame even when everything fits: resuming
1209
+ // the follow would drop the openings the reader just asked for.
1210
+ if (!this.#inspecting && (this.#follow || this.#scrollStart >= this.#maxStart)) {
1086
1211
  this.#follow = true;
1087
1212
  this.#scrollStart = this.#maxStart;
1088
1213
  }
@@ -1091,8 +1216,22 @@ export class LocalTui {
1091
1216
  return Math.max(1, this.#scrollBudget > 2 ? this.#scrollBudget - 2 : 1);
1092
1217
  }
1093
1218
  #scrollBy(delta) {
1219
+ if (this.#follow && delta > 0)
1220
+ return;
1094
1221
  if (delta === 0 && this.#maxStart === 0)
1095
1222
  return;
1223
+ if (!this.#inspecting && delta > 0 && this.#scrollStart + delta >= this.#maxStart) {
1224
+ this.#followTail();
1225
+ this.#render();
1226
+ return;
1227
+ }
1228
+ // Scrolling past the end of an inspection is how a reader goes back to
1229
+ // the live tail, the same as scrolling to the end of history does.
1230
+ if (this.#inspecting && delta > 0 && this.#scrollStart >= this.#maxStart) {
1231
+ this.#followTail();
1232
+ this.#render();
1233
+ return;
1234
+ }
1096
1235
  this.#follow = false;
1097
1236
  this.#scrollStart += delta;
1098
1237
  if (this.#scrollStart <= 0)
@@ -1102,6 +1241,47 @@ export class LocalTui {
1102
1241
  #followTail() {
1103
1242
  this.#follow = true;
1104
1243
  this.#scrollStart = this.#maxStart;
1244
+ this.#endInspection();
1245
+ }
1246
+ /** Close every opening the reader made; they belong to one inspection. */
1247
+ #endInspection() {
1248
+ if (!this.#inspecting)
1249
+ return;
1250
+ this.#inspecting = false;
1251
+ this.#toolsExpanded = false;
1252
+ this.#openedGroups.clear();
1253
+ this.#detailedTurns.clear();
1254
+ }
1255
+ /** Capture transcript navigation; overlays and disposal restore terminal ownership. */
1256
+ #setMouseTracking(enabled) {
1257
+ if (!this.#tty || enabled === this.#mouseTracking)
1258
+ return;
1259
+ this.#mouseTracking = enabled;
1260
+ this.#term.output.write(enabled ? '\x1b[?1000h\x1b[?1006h' : '\x1b[?1000l\x1b[?1006l');
1261
+ }
1262
+ /**
1263
+ * After a Ctrl+O toggle: read what is open in a browsing frame at `target`,
1264
+ * or go back to the live tail once nothing is open any more.
1265
+ */
1266
+ #inspect(target) {
1267
+ const open = this.#toolsExpanded || this.#openedGroups.size > 0;
1268
+ if (!open) {
1269
+ this.#inspecting = false;
1270
+ this.#followTail();
1271
+ return;
1272
+ }
1273
+ if (this.#inspecting)
1274
+ return;
1275
+ this.#inspecting = true;
1276
+ this.#follow = false;
1277
+ // Opening from the tail with nothing in particular under the viewport
1278
+ // keeps the reader at the end of the transcript, now in a browsing frame.
1279
+ if (target === undefined)
1280
+ this.#scrollStart = Number.MAX_SAFE_INTEGER;
1281
+ else {
1282
+ this.#focusBlock = target;
1283
+ this.#focusBlockEdge = 'end';
1284
+ }
1105
1285
  }
1106
1286
  #utf8 = new StringDecoder('utf8');
1107
1287
  #onData(chunk) {
@@ -1227,7 +1407,27 @@ export class LocalTui {
1227
1407
  await this.#acceptPastedText(text);
1228
1408
  }
1229
1409
  #dispatch(event) {
1410
+ if (event.type === 'click') {
1411
+ const target = this.#jumpToLatest;
1412
+ if (this.#mouseTracking && !this.#paste && this.#pasteInFlight === 0 && target !== undefined
1413
+ && event.row === target.row && event.column >= target.column && event.column < target.column + target.width) {
1414
+ this.#followTail();
1415
+ this.#render();
1416
+ }
1417
+ return;
1418
+ }
1419
+ if (event.type === 'wheel') {
1420
+ if (this.#mouseTracking && !this.#paste && this.#pasteInFlight === 0) {
1421
+ this.#scrollBy(event.direction === 'up' ? -3 : 3);
1422
+ }
1423
+ return;
1424
+ }
1230
1425
  if (this.#state.status === 'compacting') {
1426
+ if (event.type === 'key' && event.id === 'end' && this.#jumpToLatest !== undefined) {
1427
+ this.#followTail();
1428
+ this.#render();
1429
+ return;
1430
+ }
1231
1431
  if (event.type === 'key' && event.id === 'pageUp') {
1232
1432
  this.#scrollBy(-this.#pageSize());
1233
1433
  return;
@@ -1307,6 +1507,12 @@ export class LocalTui {
1307
1507
  this.#applyTranscriptSearch(event);
1308
1508
  return;
1309
1509
  }
1510
+ if (event.type === 'key' && event.id === 'end' && this.#jumpToLatest !== undefined
1511
+ && this.#settings === null && this.#copySelector === null && this.#search === null) {
1512
+ this.#followTail();
1513
+ this.#render();
1514
+ return;
1515
+ }
1310
1516
  if (event.type === 'key') {
1311
1517
  const action = this.#boundAction(event.id);
1312
1518
  if (action !== undefined && this.#runAction(action))
@@ -1657,21 +1863,22 @@ export class LocalTui {
1657
1863
  }
1658
1864
  #applyPrefs(prefs, options) {
1659
1865
  const previousMotion = this.#motion;
1660
- const expandChanged = prefs.expandTools !== this.#expandTools;
1866
+ const density = resolveFoldDensity(prefs);
1661
1867
  this.#themeName = prefs.theme;
1662
1868
  this.#colors = prefs.colors;
1663
1869
  this.#syncTrueColor();
1664
1870
  this.#motion = prefs.motion ?? 'full';
1665
1871
  this.#terminalProgress = prefs.terminalProgress ?? false;
1666
- this.#expandTools = prefs.expandTools;
1872
+ this.#foldDensity = density;
1873
+ this.#foldPolicy = foldPolicy(density);
1667
1874
  this.#checkUpdates = prefs.checkUpdates ?? true;
1668
1875
  this.#startupChangelog = prefs.startupChangelog ?? 'summary';
1669
1876
  this.#notificationPolicy = prefs.notifications ?? 'off';
1670
1877
  this.#notificationThreshold = prefs.notificationThreshold ?? '30s';
1671
1878
  this.#configureNotifications();
1672
1879
  this.#statusBar = resolveStatusBarConfig(prefs.statusBar, prefs.statusPreset);
1673
- if (options.forceToolsSync || expandChanged)
1674
- this.#toolsExpanded = prefs.expandTools;
1880
+ if (options.forceToolsSync)
1881
+ this.#toolsExpanded = false;
1675
1882
  if (previousMotion !== this.#motion) {
1676
1883
  this.#stopRevealTick();
1677
1884
  this.#reveal = undefined;
@@ -1708,6 +1915,36 @@ export class LocalTui {
1708
1915
  this.#render();
1709
1916
  }
1710
1917
  }
1918
+ /**
1919
+ * Write the Features section's state into the Profile patch.
1920
+ *
1921
+ * `disabled` is a Loader option rather than plugin config, so the settings
1922
+ * service cannot express it; the Profile patch is the documented surface
1923
+ * that can, and the Harness reads it while composing the tree. Only the
1924
+ * managed block is rewritten, so hand-authored rows and comments survive.
1925
+ * A failure is reported and the overlay keeps the value it shows.
1926
+ */
1927
+ #writeFeaturePatch() {
1928
+ const path = this.#featurePatch;
1929
+ const features = this.#features;
1930
+ if (path === undefined || features === undefined)
1931
+ return;
1932
+ try {
1933
+ let current = '';
1934
+ try {
1935
+ current = readFileSync(path, 'utf8');
1936
+ }
1937
+ catch {
1938
+ current = '';
1939
+ }
1940
+ mkdirSync(dirname(path), { recursive: true });
1941
+ writeFileSync(path, replaceFeatureBlock(current, renderFeatureBlock(features)), 'utf8');
1942
+ }
1943
+ catch (error) {
1944
+ this.notice(`Feature toggles were not saved: ${error instanceof Error ? error.message : String(error)}`);
1945
+ this.#render();
1946
+ }
1947
+ }
1711
1948
  #applySettings(event) {
1712
1949
  if (this.#settings === null)
1713
1950
  return;
@@ -1718,6 +1955,15 @@ export class LocalTui {
1718
1955
  return;
1719
1956
  }
1720
1957
  if (command.kind === 'apply') {
1958
+ if (command.domain === 'features') {
1959
+ if (command.state.features === undefined)
1960
+ return;
1961
+ this.#settings = command.state;
1962
+ this.#features = command.state.features;
1963
+ this.#render();
1964
+ this.#writeFeaturePatch();
1965
+ return;
1966
+ }
1721
1967
  if (command.domain === 'agent') {
1722
1968
  if (this.#agentBehaviorPending || command.state.agent === undefined)
1723
1969
  return;
@@ -1990,6 +2236,11 @@ export class LocalTui {
1990
2236
  }
1991
2237
  if (command.kind === 'suspend') {
1992
2238
  if (process.platform !== 'win32') {
2239
+ if (this.#inspecting) {
2240
+ this.#followTail();
2241
+ this.#render();
2242
+ }
2243
+ this.#setMouseTracking(false);
1993
2244
  try {
1994
2245
  process.kill(process.pid, 'SIGTSTP');
1995
2246
  }
@@ -2100,7 +2351,6 @@ export class LocalTui {
2100
2351
  this.#copySelector = null;
2101
2352
  this.#trajectory = null;
2102
2353
  this.#agentHub = null;
2103
- this.#followTail();
2104
2354
  // Image placeholders are TUI-owned. Mixed image+slash drafts stay a
2105
2355
  // submission so restore can keep the original markers; the runner strips
2106
2356
  // them only to detect and execute Harness commands.
@@ -2111,6 +2361,7 @@ export class LocalTui {
2111
2361
  this.#runSlash(slash.name, slash.args);
2112
2362
  return;
2113
2363
  }
2364
+ this.#followTail();
2114
2365
  const pending = this.#pending;
2115
2366
  if (pending !== null) {
2116
2367
  pending.offAbort?.();
@@ -2144,12 +2395,12 @@ export class LocalTui {
2144
2395
  this.#images.clear();
2145
2396
  this.#ac = null;
2146
2397
  this.#search = null;
2147
- this.#followTail();
2148
2398
  const slash = images.length === 0 ? parseSlashInput(submittedText) : null;
2149
2399
  if (slash !== null) {
2150
2400
  this.#runSlash(slash.name, slash.args);
2151
2401
  return;
2152
2402
  }
2403
+ this.#followTail();
2153
2404
  if (submittedText === '' && images.length === 0) {
2154
2405
  this.#render();
2155
2406
  return;
@@ -2165,25 +2416,13 @@ export class LocalTui {
2165
2416
  }
2166
2417
  const command = resolveSlashCommand(name, this.#commands());
2167
2418
  if (command === undefined) {
2168
- this.#notice('unknown command: /' + name);
2169
- this.#render();
2170
- return;
2171
- }
2172
- if (command.name === 'quit') {
2173
- this.#render();
2174
- this.#quit();
2175
- return;
2176
- }
2177
- if (command.name === 'clear') {
2178
- // Presentation-only reset: the agent may still own an active turn with
2179
- // live status, todos, and queued inbox state. Clearing those would make
2180
- // the next Ctrl-C (or follow-up) behave as if the session had finished.
2181
- this.#state = { ...this.#state, blocks: [] };
2182
2419
  this.#followTail();
2183
- this.#renderer.startEpoch();
2420
+ this.#notice('unknown command: /' + name);
2184
2421
  this.#render();
2185
2422
  return;
2186
2423
  }
2424
+ // Reading controls cover the current view; submitting a command that
2425
+ // opens one must preserve the same return position as a keyboard action.
2187
2426
  if (command.name === 'settings') {
2188
2427
  this.#runSettings(args);
2189
2428
  return;
@@ -2192,11 +2431,8 @@ export class LocalTui {
2192
2431
  void this.#runCopy(args);
2193
2432
  return;
2194
2433
  }
2195
- if (command.name === 'tools') {
2196
- this.#toolCatalog();
2197
- this.#render();
2198
- return;
2199
- }
2434
+ // A plugin decides whether its command opens a reading surface or changes
2435
+ // the document. Forwarding alone must not discard the return position.
2200
2436
  if (!BUILTIN_SLASH_COMMANDS.some((entry) => entry.name === command.name)) {
2201
2437
  if (this.#inspected !== undefined) {
2202
2438
  this.#notice('Return to the parent session to run /' + command.name + '.');
@@ -2216,6 +2452,27 @@ export class LocalTui {
2216
2452
  this.#render();
2217
2453
  return;
2218
2454
  }
2455
+ this.#followTail();
2456
+ if (command.name === 'quit') {
2457
+ this.#render();
2458
+ this.#quit();
2459
+ return;
2460
+ }
2461
+ if (command.name === 'clear') {
2462
+ // Presentation-only reset: the agent may still own an active turn with
2463
+ // live status, todos, and queued inbox state. Clearing those would make
2464
+ // the next Ctrl-C (or follow-up) behave as if the session had finished.
2465
+ this.#state = { ...this.#state, blocks: [transcriptSeam('cleared')] };
2466
+ this.#followTail();
2467
+ this.#renderer.startEpoch();
2468
+ this.#render();
2469
+ return;
2470
+ }
2471
+ if (command.name === 'tools') {
2472
+ this.#toolCatalog();
2473
+ this.#render();
2474
+ return;
2475
+ }
2219
2476
  if (args !== '' && args !== 'full') {
2220
2477
  this.#notice('Usage: /help [full]');
2221
2478
  this.#render();
@@ -2337,7 +2594,7 @@ export class LocalTui {
2337
2594
  }
2338
2595
  this.#search = null;
2339
2596
  this.#ac = null;
2340
- this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior);
2597
+ this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features);
2341
2598
  this.#render();
2342
2599
  }
2343
2600
  #notice(text) {
@@ -2406,7 +2663,13 @@ export class LocalTui {
2406
2663
  if (action === 'toggle-tools') {
2407
2664
  if (this.#search !== null)
2408
2665
  return false;
2409
- this.#toggleToolExpansion();
2666
+ this.#toggleFoldTarget();
2667
+ return true;
2668
+ }
2669
+ if (action === 'toggle-tool-details') {
2670
+ if (this.#search !== null)
2671
+ return false;
2672
+ this.#toggleToolDetails();
2410
2673
  return true;
2411
2674
  }
2412
2675
  if (action === 'search-history') {
@@ -2463,6 +2726,7 @@ export class LocalTui {
2463
2726
  return true;
2464
2727
  }
2465
2728
  try {
2729
+ this.#setMouseTracking(false);
2466
2730
  this.#term.input.setRawMode?.(false);
2467
2731
  const text = editExternally(this.#editor.text);
2468
2732
  this.#editor.setText(text);
@@ -2479,26 +2743,130 @@ export class LocalTui {
2479
2743
  }
2480
2744
  return true;
2481
2745
  }
2482
- /** Expand or collapse the newest tool card, or the whole tool catalog. */
2483
- #toggleToolExpansion() {
2484
- const last = this.#state.blocks.at(-1);
2485
- const tool = last?.kind === 'toolCatalog'
2486
- ? undefined
2487
- : this.#state.blocks.findLast(block => block.kind === 'tool');
2488
- if (last?.kind === 'toolCatalog') {
2489
- this.#toolsExpanded = !this.#toolsExpanded;
2746
+ /**
2747
+ * Block index the reader is looking at: the first one that starts at or below
2748
+ * the top of the transcript window. A collapsed row has to stay reachable
2749
+ * after the reader scrolls away from the tail, so the toggle aims here rather
2750
+ * than at the newest call.
2751
+ */
2752
+ #viewportBlock() {
2753
+ const starts = this.#blockStarts;
2754
+ if (starts.length === 0)
2755
+ return undefined;
2756
+ const at = this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart;
2757
+ let index = starts.findIndex(row => row >= at);
2758
+ if (index < 0)
2759
+ index = starts.length - 1;
2760
+ return index;
2761
+ }
2762
+ /** Restore the completed turn under the viewport, or close its inspection. */
2763
+ #toggleFoldTarget() {
2764
+ // Flush coalesced events so hit testing uses the blocks that are on screen.
2765
+ if (this.#streamRenderTimer !== null)
2766
+ this.#render();
2767
+ // Inside an inspection the key is the way back out: it closes what was
2768
+ // opened and returns to the live tail, wherever the reader has scrolled.
2769
+ if (this.#inspecting) {
2770
+ this.#followTail();
2771
+ this.#render();
2772
+ return;
2490
2773
  }
2491
- else if (tool?.kind === 'tool') {
2492
- if (this.#expandedToolCalls.has(tool.callId))
2493
- this.#expandedToolCalls.delete(tool.callId);
2494
- else
2495
- this.#expandedToolCalls.add(tool.callId);
2774
+ const anchor = this.#viewportBlock();
2775
+ const groups = turnGroups(this.#state).filter(item => !item.live);
2776
+ // The answer, changed-file record, and following notices still address
2777
+ // their turn when the reader is following the tail.
2778
+ const reach = (group) => {
2779
+ let at = group.until;
2780
+ const blocks = this.#state.blocks;
2781
+ while (at < blocks.length) {
2782
+ const block = blocks[at];
2783
+ if (block?.kind === 'workspace' || (block?.kind === 'notice' && block.boundary !== true)) {
2784
+ at += 1;
2785
+ continue;
2786
+ }
2787
+ break;
2788
+ }
2789
+ return at - 1;
2790
+ };
2791
+ const group = anchor === undefined
2792
+ ? undefined
2793
+ : groups.findLast(item => {
2794
+ const first = this.#state.blocks[item.start - 1]?.kind === 'user' ? item.start - 1 : item.start;
2795
+ return anchor >= first && anchor <= reach(item);
2796
+ });
2797
+ if (group !== undefined) {
2798
+ this.#toggleSet(this.#openedGroups, group.key);
2799
+ this.#inspect((group.answer ?? group.until) - 1);
2800
+ this.#render();
2801
+ return;
2496
2802
  }
2497
- else {
2803
+ if (anchor !== undefined && this.#state.blocks[anchor]?.kind === 'toolCatalog') {
2498
2804
  this.#toolsExpanded = !this.#toolsExpanded;
2805
+ this.#inspect(anchor);
2806
+ this.#render();
2807
+ }
2808
+ // A live turn already has the process presentation; Alt+O owns tool details.
2809
+ }
2810
+ #detailScope(group) {
2811
+ return group.turn === undefined ? group.key : `turn:${group.turn}`;
2812
+ }
2813
+ #detailedToolIds() {
2814
+ const calls = new Set();
2815
+ if (this.#detailedTurns.size === 0)
2816
+ return calls;
2817
+ for (const group of turnGroups(this.#state)) {
2818
+ if (!this.#detailedTurns.has(this.#detailScope(group)))
2819
+ continue;
2820
+ for (let at = group.start; at < group.until; at++) {
2821
+ const block = this.#state.blocks[at];
2822
+ if (block?.kind === 'tool')
2823
+ calls.add(block.callId);
2824
+ }
2499
2825
  }
2826
+ return calls;
2827
+ }
2828
+ #toggleToolDetails() {
2829
+ if (this.#streamRenderTimer !== null)
2830
+ this.#render();
2831
+ // A long tool can fill the viewport. Target the block containing its top,
2832
+ // rather than the next block starting below it (possibly another turn).
2833
+ const anchor = this.#follow ? this.#state.blocks.length - 1
2834
+ : Math.max(0, this.#blockStarts.findLastIndex(row => row <= this.#scrollStart));
2835
+ const groups = turnGroups(this.#state);
2836
+ const group = groups.findLast(item => {
2837
+ const first = this.#state.blocks[item.start - 1]?.kind === 'user' ? item.start - 1 : item.start;
2838
+ let end = item.until;
2839
+ while (end < this.#state.blocks.length) {
2840
+ const block = this.#state.blocks[end];
2841
+ if (block?.kind !== 'workspace' && !(block?.kind === 'notice' && block.boundary !== true))
2842
+ break;
2843
+ end++;
2844
+ }
2845
+ return anchor >= first && anchor < end;
2846
+ });
2847
+ if (group === undefined)
2848
+ return;
2849
+ const scope = this.#detailScope(group);
2850
+ const members = groups.filter(item => this.#detailScope(item) === scope);
2851
+ if (members.every(member => member.calls === 0))
2852
+ return;
2853
+ this.#toggleSet(this.#detailedTurns, scope);
2854
+ for (const member of members)
2855
+ this.#openedGroups.add(member.key);
2856
+ const tools = members.flatMap(member => this.#state.blocks.slice(member.start, member.until)
2857
+ .flatMap((block, offset) => block.kind === 'tool' ? [member.start + offset] : []));
2858
+ const target = tools.includes(anchor) ? anchor : tools[0];
2859
+ this.#inspect(target);
2860
+ this.#focusBlock = target;
2861
+ this.#focusBlockEdge = 'start';
2500
2862
  this.#render();
2501
2863
  }
2864
+ #toggleSet(set, key) {
2865
+ if (set.has(key))
2866
+ set.delete(key);
2867
+ else
2868
+ set.add(key);
2869
+ }
2502
2870
  /** Fold one event into the active transcript search. */
2503
2871
  #applyTranscriptSearch(event) {
2504
2872
  const state = this.#transcriptSearch;
@@ -2541,6 +2909,10 @@ export function apply(ctx, config) {
2541
2909
  herdrReporter: new HerdrAgentReporter(),
2542
2910
  historyPath: config.historyPath ?? join(dshHome, 'omdsh', 'history.jsonl'),
2543
2911
  keybindingsPath: config.keybindingsPath ?? join(dshHome, 'omdsh', 'keybindings.json'),
2912
+ ...(() => {
2913
+ const path = featurePatchPath(config.dshHome ?? dshHome);
2914
+ return existsSync(path) ? { featurePatch: path } : {};
2915
+ })(),
2544
2916
  });
2545
2917
  ctx.provide(TUI_SERVICE, tui);
2546
2918
  ctx.effect(() => () => { tui.dispose(); });
@@ -2554,7 +2926,7 @@ export function apply(ctx, config) {
2554
2926
  colors: resolveColors(config.colors.get(), term.output.isTTY === true),
2555
2927
  motion: config.motion.get(),
2556
2928
  terminalProgress: config.terminalProgress.get(),
2557
- expandTools: config.expandTools.get(),
2929
+ foldDensity: config.foldDensity.get(),
2558
2930
  checkUpdates: config.checkUpdates.get(),
2559
2931
  startupChangelog: config.startupChangelog.get(),
2560
2932
  notifications: config.notifications.get(),