@agi-fans/dsh-tui 0.17.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 (100) hide show
  1. package/lib/CHANGELOG.md +67 -1
  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/status-config.d.ts +4 -1
  13. package/lib/chrome/status-config.js.map +1 -1
  14. package/lib/chrome/theme.d.ts +18 -0
  15. package/lib/chrome/theme.js +18 -0
  16. package/lib/chrome/theme.js.map +1 -1
  17. package/lib/chrome/tool-args.d.ts +24 -1
  18. package/lib/chrome/tool-args.js +40 -1
  19. package/lib/chrome/tool-args.js.map +1 -1
  20. package/lib/chrome/tool-renderers.d.ts +11 -0
  21. package/lib/chrome/tool-renderers.js +55 -7
  22. package/lib/chrome/tool-renderers.js.map +1 -1
  23. package/lib/chrome/welcome-tips.js +1 -1
  24. package/lib/chrome/welcome-tips.js.map +1 -1
  25. package/lib/commands/auth.js +4 -2
  26. package/lib/commands/auth.js.map +1 -1
  27. package/lib/commands/jobs.d.ts +2 -2
  28. package/lib/commands/jobs.js +2 -2
  29. package/lib/commands/jobs.js.map +1 -1
  30. package/lib/definition.d.ts +62 -7
  31. package/lib/definition.js.map +1 -1
  32. package/lib/index.d.ts +1 -1
  33. package/lib/index.js +1 -1
  34. package/lib/index.js.map +1 -1
  35. package/lib/input/keybindings-config.d.ts +1 -1
  36. package/lib/input/keybindings-config.js +2 -1
  37. package/lib/input/keybindings-config.js.map +1 -1
  38. package/lib/input/keys.d.ts +7 -0
  39. package/lib/input/keys.js +11 -4
  40. package/lib/input/keys.js.map +1 -1
  41. package/lib/runtime/provider-local.d.ts +63 -11
  42. package/lib/runtime/provider-local.js +506 -91
  43. package/lib/runtime/provider-local.js.map +1 -1
  44. package/lib/runtime/session-runtime.d.ts +9 -1
  45. package/lib/runtime/session-runtime.js +2 -2
  46. package/lib/runtime/session-runtime.js.map +1 -1
  47. package/lib/runtime/startup-notices.js +6 -9
  48. package/lib/runtime/startup-notices.js.map +1 -1
  49. package/lib/runtime/tool-presentation.js +44 -31
  50. package/lib/runtime/tool-presentation.js.map +1 -1
  51. package/lib/session/feature-toggles.d.ts +100 -0
  52. package/lib/session/feature-toggles.js +170 -0
  53. package/lib/session/feature-toggles.js.map +1 -0
  54. package/lib/session/fold-policy.d.ts +21 -0
  55. package/lib/session/fold-policy.js +15 -0
  56. package/lib/session/fold-policy.js.map +1 -0
  57. package/lib/session/job-notice.d.ts +6 -2
  58. package/lib/session/job-notice.js +44 -3
  59. package/lib/session/job-notice.js.map +1 -1
  60. package/lib/session/session-configuration.d.ts +0 -3
  61. package/lib/session/session-configuration.js +0 -4
  62. package/lib/session/session-configuration.js.map +1 -1
  63. package/lib/session/session-controller.d.ts +6 -4
  64. package/lib/session/session-controller.js +147 -55
  65. package/lib/session/session-controller.js.map +1 -1
  66. package/lib/session/session-library.d.ts +20 -0
  67. package/lib/session/session-library.js +52 -0
  68. package/lib/session/session-library.js.map +1 -1
  69. package/lib/session/subagent-roster.js +3 -3
  70. package/lib/session/subagent-roster.js.map +1 -1
  71. package/lib/session/subagent-tracker.d.ts +2 -9
  72. package/lib/session/subagent-tracker.js +3 -4
  73. package/lib/session/subagent-tracker.js.map +1 -1
  74. package/lib/session/tui-settings.d.ts +46 -18
  75. package/lib/session/tui-settings.js +59 -39
  76. package/lib/session/tui-settings.js.map +1 -1
  77. package/lib/views/at-complete.d.ts +2 -0
  78. package/lib/views/at-complete.js +2 -2
  79. package/lib/views/at-complete.js.map +1 -1
  80. package/lib/views/event-views.d.ts +26 -2
  81. package/lib/views/event-views.js +162 -48
  82. package/lib/views/event-views.js.map +1 -1
  83. package/lib/views/hotkeys.d.ts +2 -0
  84. package/lib/views/hotkeys.js +7 -3
  85. package/lib/views/hotkeys.js.map +1 -1
  86. package/lib/views/settings-list.d.ts +12 -6
  87. package/lib/views/settings-list.js +41 -18
  88. package/lib/views/settings-list.js.map +1 -1
  89. package/lib/views/transcript-export.js +0 -2
  90. package/lib/views/transcript-export.js.map +1 -1
  91. package/lib/views/transcript-render.d.ts +133 -18
  92. package/lib/views/transcript-render.js +1179 -112
  93. package/lib/views/transcript-render.js.map +1 -1
  94. package/lib/views/transcript-types.d.ts +79 -1
  95. package/lib/views/transcript-types.js +11 -0
  96. package/lib/views/transcript-types.js.map +1 -1
  97. package/lib/views/workspace-changes.d.ts +27 -0
  98. package/lib/views/workspace-changes.js +128 -0
  99. package/lib/views/workspace-changes.js.map +1 -0
  100. package/package.json +40 -38
@@ -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,22 +34,24 @@ 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";
37
41
  import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
38
- import { colorDisabledByEnv, detectTrueColor, parseThemeName } from "../chrome/theme.js";
42
+ import { colorDisabledByEnv, detectTrueColor } from "../chrome/theme.js";
39
43
  import { renderTool } from "../chrome/tool-renderers.js";
40
- import { TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "../session/tui-settings.js";
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";
44
49
  import { editExternally } from "../input/external-editor.js";
50
+ import z from '@deepseek-ai/schemastery';
45
51
  import { movePromptSelection, filteredPromptOptions, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, } from "../views/prompt-selector.js";
46
52
  import { refreshProjectContext, resolveProjectContext } from "../session/project-context.js";
47
53
  import { pickWelcomeTips } from "../chrome/welcome-tips.js";
48
- import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount } from "../views/hotkeys.js";
54
+ import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount, keysForAction } from "../views/hotkeys.js";
49
55
  import { imagePathCandidates, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
50
56
  import { APP_NAME, APP_VERSION } from "../session/package-metadata.js";
51
57
  const DOUBLE_CTRL_C_MS = 500;
@@ -89,6 +95,18 @@ function windowTitleText(title) {
89
95
  return [...cleaned].slice(0, WINDOW_TITLE_MAX).join('');
90
96
  }
91
97
  export const name = 'omdsh-tui';
98
+ /**
99
+ * Row schema. The durable preferences ride as volatile fields, so a settings
100
+ * form edit commits into their references and reaches this fiber through
101
+ * `loader/volatile-update` without remounting the terminal provider.
102
+ */
103
+ export const Config = z.object({
104
+ model: z.string(),
105
+ historyPath: z.string(),
106
+ keybindingsPath: z.string(),
107
+ dshHome: z.string(),
108
+ ...TUI_SETTINGS_FIELDS,
109
+ });
92
110
  /**
93
111
  * Local terminal presentation service.
94
112
  */
@@ -110,6 +128,25 @@ function resolveColors(preference, isTty) {
110
128
  return preference;
111
129
  return isTty && !colorDisabledByEnv();
112
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
+ }
113
150
  export class LocalTui {
114
151
  #deferInitialRender = false;
115
152
  #terminalProfile;
@@ -133,6 +170,10 @@ export class LocalTui {
133
170
  #search = null;
134
171
  #transcriptSearch = null;
135
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;
136
177
  #copySelector = null;
137
178
  #trajectory = null;
138
179
  #agentHub = null;
@@ -179,9 +220,16 @@ export class LocalTui {
179
220
  #scrollStart = 0;
180
221
  #maxStart = 0;
181
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;
182
228
  #follow = true;
183
229
  #focusBlock;
184
- #expandTools = false;
230
+ #focusBlockEdge;
231
+ #foldDensity = DEFAULT_FOLD_DENSITY;
232
+ #foldPolicy = foldPolicy(DEFAULT_FOLD_DENSITY);
185
233
  #motion = 'full';
186
234
  #terminalProgress = false;
187
235
  #terminalProgressActive = false;
@@ -199,7 +247,24 @@ export class LocalTui {
199
247
  #notificationThreshold = '30s';
200
248
  #statusBar = defaultStatusBarConfig();
201
249
  #toolsExpanded = false;
202
- #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();
203
268
  #tools = [];
204
269
  #runtimeCommands = [];
205
270
  #prompt = null;
@@ -269,6 +334,17 @@ export class LocalTui {
269
334
  this.#historyStore = paths.historyPath === undefined ? undefined : new HistoryStore(paths.historyPath);
270
335
  this.#history = this.#historyStore?.load() ?? [];
271
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
+ }
272
348
  const fallback = defaultPathSource();
273
349
  this.#cwd = paths.cwd ?? fallback.cwd;
274
350
  this.#readImagePath = paths.readImagePath ?? (path => readImageFile(path, this.#cwd));
@@ -300,7 +376,6 @@ export class LocalTui {
300
376
  width: this.#term.width(),
301
377
  height: this.#term.height(),
302
378
  synchronized: this.#tty,
303
- clearScrollback: this.#terminalProfile === 'direct',
304
379
  alternateScreenOverlays: paths.alternateScreenOverlays === true,
305
380
  });
306
381
  if (this.#tty) {
@@ -340,8 +415,15 @@ export class LocalTui {
340
415
  if (this.#tty)
341
416
  this.#render();
342
417
  }
343
- event(event, presentation) {
344
- 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);
345
427
  this.#emitNotification(this.#notifications.event({
346
428
  type: event.type,
347
429
  time: event.time,
@@ -368,6 +450,16 @@ export class LocalTui {
368
450
  this.#plain.print();
369
451
  }
370
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
+ }
371
463
  /**
372
464
  * Fold one live `agent/assistant-stream` chunk into the transcript and
373
465
  * schedule the streaming reveal. Durable settlement follows on the session
@@ -532,20 +624,41 @@ export class LocalTui {
532
624
  .join(' · ');
533
625
  }
534
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;
535
641
  const block = {
536
642
  kind: 'notice',
537
643
  level: options.level ?? 'info',
538
644
  text,
539
645
  ...(options.framed === true ? { framed: true } : {}),
646
+ ...(running
647
+ ? { process: { turn: this.#state.turn, ...(options.processSource === undefined ? {} : { source: options.processSource }) } }
648
+ : {}),
540
649
  };
541
650
  this.#state = { ...this.#state, blocks: [...this.#state.blocks, block] };
651
+ if (options.focus === true)
652
+ this.#followTail();
542
653
  if (this.#tty)
543
654
  this.#render();
544
655
  else
545
656
  this.#plain.print();
546
657
  }
547
- commandOutput(command, text) {
658
+ commandOutput(command, text, options = {}) {
548
659
  this.#state = { ...this.#state, blocks: [...this.#state.blocks, { kind: 'commandOutput', command, text }] };
660
+ if (options.focus === true)
661
+ this.#followTail();
549
662
  if (this.#tty)
550
663
  this.#render();
551
664
  else
@@ -590,14 +703,18 @@ export class LocalTui {
590
703
  }
591
704
  });
592
705
  }
593
- replaceSession(events, presentations, status = 'idle') {
706
+ replaceSession(events, presentations, status = 'idle', reason = 'open') {
594
707
  this.#trajectory = null;
595
708
  const state = replayEvents(events, presentations);
596
- 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 };
597
714
  this.#plain.resetPrinted();
598
715
  this.#followTail();
599
716
  this.#deferInitialRender = false;
600
- this.#renderer.startEpoch({ replay: status === 'idle' ? 'full' : 'pinned' });
717
+ this.#renderer.startEpoch({ replay: reason === 'refresh' ? 'viewport' : status === 'idle' ? 'full' : 'pinned' });
601
718
  if (this.#tty)
602
719
  this.#render();
603
720
  else
@@ -645,7 +762,7 @@ export class LocalTui {
645
762
  #syncTrueColor() {
646
763
  this.#trueColor = this.#colors && detectTrueColor();
647
764
  }
648
- /** Apply prefs loaded from the settings document (does not persist). */
765
+ /** Apply prefs loaded from this entry's config (does not persist). */
649
766
  applyStoredPrefs(prefs) {
650
767
  this.#applyPrefs(prefs, { persist: false, forceToolsSync: true });
651
768
  if (this.#settings !== null)
@@ -653,6 +770,29 @@ export class LocalTui {
653
770
  if (this.#tty)
654
771
  this.#render();
655
772
  }
773
+ /** Currently applied preferences; sibling rows read these instead of the config references. */
774
+ prefs() {
775
+ return {
776
+ theme: this.#themeName,
777
+ colors: this.#colors,
778
+ motion: this.#motion,
779
+ terminalProgress: this.#terminalProgress,
780
+ foldDensity: this.#foldDensity,
781
+ checkUpdates: this.#checkUpdates,
782
+ startupChangelog: this.#startupChangelog,
783
+ notifications: this.#notificationPolicy,
784
+ notificationThreshold: this.#notificationThreshold,
785
+ statusBar: {
786
+ ...this.#statusBar,
787
+ groups: [...this.#statusBar.groups],
788
+ ...(this.#statusBar.order === undefined ? {} : { order: [...this.#statusBar.order] }),
789
+ ...(this.#statusBar.meta === undefined ? {} : { meta: [...this.#statusBar.meta] }),
790
+ ...(this.#statusBar.metaOrder === undefined ? {} : { metaOrder: [...this.#statusBar.metaOrder] }),
791
+ ...(this.#statusBar.colors === undefined ? {} : { colors: { ...this.#statusBar.colors } }),
792
+ ...(this.#statusBar.sides === undefined ? {} : { sides: { ...this.#statusBar.sides } }),
793
+ },
794
+ };
795
+ }
656
796
  /** Called after a live `/settings` change. */
657
797
  setPrefsPersist(persist) {
658
798
  this.#persistPrefs = persist;
@@ -675,7 +815,7 @@ export class LocalTui {
675
815
  this.#agentBehavior = undefined;
676
816
  this.#agentBehaviorPending = false;
677
817
  if (this.#settings !== null)
678
- this.#settings = createSettings(this.#prefs());
818
+ this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features);
679
819
  if (this.#tty && !this.#disposed)
680
820
  this.#render();
681
821
  };
@@ -804,6 +944,7 @@ export class LocalTui {
804
944
  if (this.#tty) {
805
945
  this.#offData?.();
806
946
  this.#offResize?.();
947
+ this.#setMouseTracking(false);
807
948
  this.#term.input.setRawMode?.(false);
808
949
  // Leave the cursor on a fresh line below the last frame so the shell
809
950
  // prompt does not overwrite the transcript. Disable bracketed paste
@@ -989,7 +1130,7 @@ export class LocalTui {
989
1130
  trueColor: this.#trueColor,
990
1131
  themeName: this.#themeName,
991
1132
  scrollStart: this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart,
992
- ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock }),
1133
+ ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock, focusBlockEdge: this.#focusBlockEdge ?? 'start' }),
993
1134
  ...(this.#transcriptSearch === null ? {} : {
994
1135
  transcriptSearch: {
995
1136
  query: this.#transcriptSearch.query,
@@ -999,7 +1140,10 @@ export class LocalTui {
999
1140
  },
1000
1141
  }),
1001
1142
  toolsExpanded: this.#toolsExpanded,
1002
- expandedTools: this.#expandedToolCalls,
1143
+ expandedTools: this.#detailedToolIds(),
1144
+ toolDetailsKey: keysForAction(this.#keybindings, 'toggle-tool-details'),
1145
+ fold: this.#foldPolicy,
1146
+ openedGroups: this.#openedGroups,
1003
1147
  commands: this.#commands(),
1004
1148
  recentSessions: this.#recentSessions,
1005
1149
  welcomeTips: this.#welcomeTips,
@@ -1023,8 +1167,23 @@ export class LocalTui {
1023
1167
  })
1024
1168
  : { lines: [] };
1025
1169
  this.#focusBlock = undefined;
1170
+ this.#focusBlockEdge = undefined;
1026
1171
  this.#promptDocument = frame.promptDocument;
1027
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
+ }
1028
1187
  this.#renderer.render(frame);
1029
1188
  }
1030
1189
  #scheduleStreamRender() {
@@ -1038,16 +1197,17 @@ export class LocalTui {
1038
1197
  }
1039
1198
  #syncScroll(scroll) {
1040
1199
  if (scroll === undefined) {
1041
- this.#scrollStart = 0;
1042
- this.#maxStart = 0;
1043
- this.#scrollBudget = 0;
1044
- 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.
1045
1202
  return;
1046
1203
  }
1047
1204
  this.#scrollStart = scroll.start;
1048
1205
  this.#maxStart = scroll.maxStart;
1049
1206
  this.#scrollBudget = scroll.budget;
1050
- 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)) {
1051
1211
  this.#follow = true;
1052
1212
  this.#scrollStart = this.#maxStart;
1053
1213
  }
@@ -1056,8 +1216,22 @@ export class LocalTui {
1056
1216
  return Math.max(1, this.#scrollBudget > 2 ? this.#scrollBudget - 2 : 1);
1057
1217
  }
1058
1218
  #scrollBy(delta) {
1219
+ if (this.#follow && delta > 0)
1220
+ return;
1059
1221
  if (delta === 0 && this.#maxStart === 0)
1060
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
+ }
1061
1235
  this.#follow = false;
1062
1236
  this.#scrollStart += delta;
1063
1237
  if (this.#scrollStart <= 0)
@@ -1067,6 +1241,47 @@ export class LocalTui {
1067
1241
  #followTail() {
1068
1242
  this.#follow = true;
1069
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
+ }
1070
1285
  }
1071
1286
  #utf8 = new StringDecoder('utf8');
1072
1287
  #onData(chunk) {
@@ -1192,7 +1407,27 @@ export class LocalTui {
1192
1407
  await this.#acceptPastedText(text);
1193
1408
  }
1194
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
+ }
1195
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
+ }
1196
1431
  if (event.type === 'key' && event.id === 'pageUp') {
1197
1432
  this.#scrollBy(-this.#pageSize());
1198
1433
  return;
@@ -1272,6 +1507,12 @@ export class LocalTui {
1272
1507
  this.#applyTranscriptSearch(event);
1273
1508
  return;
1274
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
+ }
1275
1516
  if (event.type === 'key') {
1276
1517
  const action = this.#boundAction(event.id);
1277
1518
  if (action !== undefined && this.#runAction(action))
@@ -1620,45 +1861,24 @@ export class LocalTui {
1620
1861
  this.#agentHub = createAgentHub({ agents });
1621
1862
  this.#render();
1622
1863
  }
1623
- #prefs() {
1624
- return {
1625
- theme: this.#themeName,
1626
- colors: this.#colors,
1627
- motion: this.#motion,
1628
- terminalProgress: this.#terminalProgress,
1629
- expandTools: this.#expandTools,
1630
- checkUpdates: this.#checkUpdates,
1631
- startupChangelog: this.#startupChangelog,
1632
- notifications: this.#notificationPolicy,
1633
- notificationThreshold: this.#notificationThreshold,
1634
- statusBar: {
1635
- ...this.#statusBar,
1636
- groups: [...this.#statusBar.groups],
1637
- ...(this.#statusBar.order === undefined ? {} : { order: [...this.#statusBar.order] }),
1638
- ...(this.#statusBar.meta === undefined ? {} : { meta: [...this.#statusBar.meta] }),
1639
- ...(this.#statusBar.metaOrder === undefined ? {} : { metaOrder: [...this.#statusBar.metaOrder] }),
1640
- ...(this.#statusBar.colors === undefined ? {} : { colors: { ...this.#statusBar.colors } }),
1641
- ...(this.#statusBar.sides === undefined ? {} : { sides: { ...this.#statusBar.sides } }),
1642
- },
1643
- };
1644
- }
1645
1864
  #applyPrefs(prefs, options) {
1646
1865
  const previousMotion = this.#motion;
1647
- const expandChanged = prefs.expandTools !== this.#expandTools;
1866
+ const density = resolveFoldDensity(prefs);
1648
1867
  this.#themeName = prefs.theme;
1649
1868
  this.#colors = prefs.colors;
1650
1869
  this.#syncTrueColor();
1651
1870
  this.#motion = prefs.motion ?? 'full';
1652
1871
  this.#terminalProgress = prefs.terminalProgress ?? false;
1653
- this.#expandTools = prefs.expandTools;
1872
+ this.#foldDensity = density;
1873
+ this.#foldPolicy = foldPolicy(density);
1654
1874
  this.#checkUpdates = prefs.checkUpdates ?? true;
1655
1875
  this.#startupChangelog = prefs.startupChangelog ?? 'summary';
1656
1876
  this.#notificationPolicy = prefs.notifications ?? 'off';
1657
1877
  this.#notificationThreshold = prefs.notificationThreshold ?? '30s';
1658
1878
  this.#configureNotifications();
1659
1879
  this.#statusBar = resolveStatusBarConfig(prefs.statusBar, prefs.statusPreset);
1660
- if (options.forceToolsSync || expandChanged)
1661
- this.#toolsExpanded = prefs.expandTools;
1880
+ if (options.forceToolsSync)
1881
+ this.#toolsExpanded = false;
1662
1882
  if (previousMotion !== this.#motion) {
1663
1883
  this.#stopRevealTick();
1664
1884
  this.#reveal = undefined;
@@ -1695,6 +1915,36 @@ export class LocalTui {
1695
1915
  this.#render();
1696
1916
  }
1697
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
+ }
1698
1948
  #applySettings(event) {
1699
1949
  if (this.#settings === null)
1700
1950
  return;
@@ -1705,6 +1955,15 @@ export class LocalTui {
1705
1955
  return;
1706
1956
  }
1707
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
+ }
1708
1967
  if (command.domain === 'agent') {
1709
1968
  if (this.#agentBehaviorPending || command.state.agent === undefined)
1710
1969
  return;
@@ -1977,6 +2236,11 @@ export class LocalTui {
1977
2236
  }
1978
2237
  if (command.kind === 'suspend') {
1979
2238
  if (process.platform !== 'win32') {
2239
+ if (this.#inspecting) {
2240
+ this.#followTail();
2241
+ this.#render();
2242
+ }
2243
+ this.#setMouseTracking(false);
1980
2244
  try {
1981
2245
  process.kill(process.pid, 'SIGTSTP');
1982
2246
  }
@@ -2087,7 +2351,6 @@ export class LocalTui {
2087
2351
  this.#copySelector = null;
2088
2352
  this.#trajectory = null;
2089
2353
  this.#agentHub = null;
2090
- this.#followTail();
2091
2354
  // Image placeholders are TUI-owned. Mixed image+slash drafts stay a
2092
2355
  // submission so restore can keep the original markers; the runner strips
2093
2356
  // them only to detect and execute Harness commands.
@@ -2098,6 +2361,7 @@ export class LocalTui {
2098
2361
  this.#runSlash(slash.name, slash.args);
2099
2362
  return;
2100
2363
  }
2364
+ this.#followTail();
2101
2365
  const pending = this.#pending;
2102
2366
  if (pending !== null) {
2103
2367
  pending.offAbort?.();
@@ -2131,12 +2395,12 @@ export class LocalTui {
2131
2395
  this.#images.clear();
2132
2396
  this.#ac = null;
2133
2397
  this.#search = null;
2134
- this.#followTail();
2135
2398
  const slash = images.length === 0 ? parseSlashInput(submittedText) : null;
2136
2399
  if (slash !== null) {
2137
2400
  this.#runSlash(slash.name, slash.args);
2138
2401
  return;
2139
2402
  }
2403
+ this.#followTail();
2140
2404
  if (submittedText === '' && images.length === 0) {
2141
2405
  this.#render();
2142
2406
  return;
@@ -2152,25 +2416,13 @@ export class LocalTui {
2152
2416
  }
2153
2417
  const command = resolveSlashCommand(name, this.#commands());
2154
2418
  if (command === undefined) {
2155
- this.#notice('unknown command: /' + name);
2156
- this.#render();
2157
- return;
2158
- }
2159
- if (command.name === 'quit') {
2160
- this.#render();
2161
- this.#quit();
2162
- return;
2163
- }
2164
- if (command.name === 'clear') {
2165
- // Presentation-only reset: the agent may still own an active turn with
2166
- // live status, todos, and queued inbox state. Clearing those would make
2167
- // the next Ctrl-C (or follow-up) behave as if the session had finished.
2168
- this.#state = { ...this.#state, blocks: [] };
2169
2419
  this.#followTail();
2170
- this.#renderer.startEpoch();
2420
+ this.#notice('unknown command: /' + name);
2171
2421
  this.#render();
2172
2422
  return;
2173
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.
2174
2426
  if (command.name === 'settings') {
2175
2427
  this.#runSettings(args);
2176
2428
  return;
@@ -2179,11 +2431,8 @@ export class LocalTui {
2179
2431
  void this.#runCopy(args);
2180
2432
  return;
2181
2433
  }
2182
- if (command.name === 'tools') {
2183
- this.#toolCatalog();
2184
- this.#render();
2185
- return;
2186
- }
2434
+ // A plugin decides whether its command opens a reading surface or changes
2435
+ // the document. Forwarding alone must not discard the return position.
2187
2436
  if (!BUILTIN_SLASH_COMMANDS.some((entry) => entry.name === command.name)) {
2188
2437
  if (this.#inspected !== undefined) {
2189
2438
  this.#notice('Return to the parent session to run /' + command.name + '.');
@@ -2203,6 +2452,27 @@ export class LocalTui {
2203
2452
  this.#render();
2204
2453
  return;
2205
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
+ }
2206
2476
  if (args !== '' && args !== 'full') {
2207
2477
  this.#notice('Usage: /help [full]');
2208
2478
  this.#render();
@@ -2324,7 +2594,7 @@ export class LocalTui {
2324
2594
  }
2325
2595
  this.#search = null;
2326
2596
  this.#ac = null;
2327
- this.#settings = createSettings(this.#prefs(), undefined, this.#agentBehavior);
2597
+ this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features);
2328
2598
  this.#render();
2329
2599
  }
2330
2600
  #notice(text) {
@@ -2393,7 +2663,13 @@ export class LocalTui {
2393
2663
  if (action === 'toggle-tools') {
2394
2664
  if (this.#search !== null)
2395
2665
  return false;
2396
- 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();
2397
2673
  return true;
2398
2674
  }
2399
2675
  if (action === 'search-history') {
@@ -2450,6 +2726,7 @@ export class LocalTui {
2450
2726
  return true;
2451
2727
  }
2452
2728
  try {
2729
+ this.#setMouseTracking(false);
2453
2730
  this.#term.input.setRawMode?.(false);
2454
2731
  const text = editExternally(this.#editor.text);
2455
2732
  this.#editor.setText(text);
@@ -2466,26 +2743,130 @@ export class LocalTui {
2466
2743
  }
2467
2744
  return true;
2468
2745
  }
2469
- /** Expand or collapse the newest tool card, or the whole tool catalog. */
2470
- #toggleToolExpansion() {
2471
- const last = this.#state.blocks.at(-1);
2472
- const tool = last?.kind === 'toolCatalog'
2473
- ? undefined
2474
- : this.#state.blocks.findLast(block => block.kind === 'tool');
2475
- if (last?.kind === 'toolCatalog') {
2476
- 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;
2477
2773
  }
2478
- else if (tool?.kind === 'tool') {
2479
- if (this.#expandedToolCalls.has(tool.callId))
2480
- this.#expandedToolCalls.delete(tool.callId);
2481
- else
2482
- 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;
2483
2802
  }
2484
- else {
2803
+ if (anchor !== undefined && this.#state.blocks[anchor]?.kind === 'toolCatalog') {
2485
2804
  this.#toolsExpanded = !this.#toolsExpanded;
2805
+ this.#inspect(anchor);
2806
+ this.#render();
2486
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
+ }
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';
2487
2862
  this.#render();
2488
2863
  }
2864
+ #toggleSet(set, key) {
2865
+ if (set.has(key))
2866
+ set.delete(key);
2867
+ else
2868
+ set.add(key);
2869
+ }
2489
2870
  /** Fold one event into the active transcript search. */
2490
2871
  #applyTranscriptSearch(event) {
2491
2872
  const state = this.#transcriptSearch;
@@ -2521,21 +2902,55 @@ export function apply(ctx, config) {
2521
2902
  return () => { process.stdout.removeListener('resize', listener); };
2522
2903
  },
2523
2904
  };
2524
- const tui = new LocalTui(term, config.model, config.colors, parseThemeName(config.theme), copyToClipboard, {
2905
+ const tui = new LocalTui(term, config.model, config.colors.get(), config.theme.get(), copyToClipboard, {
2525
2906
  deferInitialRender: true,
2526
2907
  terminalProfile,
2527
2908
  alternateScreenOverlays: terminalProfile === 'direct',
2528
2909
  herdrReporter: new HerdrAgentReporter(),
2529
2910
  historyPath: config.historyPath ?? join(dshHome, 'omdsh', 'history.jsonl'),
2530
2911
  keybindingsPath: config.keybindingsPath ?? join(dshHome, 'omdsh', 'keybindings.json'),
2912
+ ...(() => {
2913
+ const path = featurePatchPath(config.dshHome ?? dshHome);
2914
+ return existsSync(path) ? { featurePatch: path } : {};
2915
+ })(),
2531
2916
  });
2532
2917
  ctx.provide(TUI_SERVICE, tui);
2533
2918
  ctx.effect(() => () => { tui.dispose(); });
2919
+ // Preferences are this entry's own volatile config: the initial read applies
2920
+ // them without persisting, a committed form edit dispatches a volatile update
2921
+ // to this fiber only, and an in-app `/settings` change writes back through the
2922
+ // settings service, which persists the edit into the active profile patch.
2923
+ const storedPrefs = () => {
2924
+ const prefs = {
2925
+ theme: config.theme.get(),
2926
+ colors: resolveColors(config.colors.get(), term.output.isTTY === true),
2927
+ motion: config.motion.get(),
2928
+ terminalProgress: config.terminalProgress.get(),
2929
+ foldDensity: config.foldDensity.get(),
2930
+ checkUpdates: config.checkUpdates.get(),
2931
+ startupChangelog: config.startupChangelog.get(),
2932
+ notifications: config.notifications.get(),
2933
+ notificationThreshold: config.notificationThreshold.get(),
2934
+ };
2935
+ const statusBar = config.statusBar.get();
2936
+ // The reference holds a frozen snapshot; the provider keeps a mutable copy.
2937
+ if (statusBar !== undefined)
2938
+ prefs.statusBar = structuredClone(statusBar);
2939
+ const statusPreset = config.statusPreset.get();
2940
+ if (statusPreset !== undefined)
2941
+ prefs.statusPreset = statusPreset;
2942
+ return prefs;
2943
+ };
2944
+ tui.applyStoredPrefs(storedPrefs());
2945
+ ctx.on('loader/volatile-update', () => { tui.applyStoredPrefs(storedPrefs()); });
2946
+ // Persistence needs the settings service; without one the provider still runs
2947
+ // and an in-app change stays session-local, as it did before.
2534
2948
  ctx.inject(['settings'], (settingsCtx) => {
2535
- const scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema, { base: { theme: parseThemeName(config.theme), colors: resolveColors(config.colors, term.output.isTTY === true), expandTools: false } });
2536
- tui.applyStoredPrefs(scope.get());
2537
- tui.setPrefsPersist((prefs) => { void scope.update(prefs); });
2538
- scope.watch((next) => { tui.applyStoredPrefs(next); });
2949
+ tui.setPrefsPersist((prefs) => {
2950
+ const ops = Object.entries(prefs).map(([key, value]) => ({ op: 'set', path: [key], value }));
2951
+ void settingsCtx.settings.mutate(TUI_SETTINGS_ENTRY, ops)
2952
+ .catch((error) => { settingsCtx.logger.warn(error); });
2953
+ });
2539
2954
  });
2540
2955
  }
2541
2956
  //# sourceMappingURL=provider-local.js.map