@kolisachint/hoocode-agent 0.5.57 → 0.5.59

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 (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/dist/core/agent-session.d.ts +2 -2
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +4 -3
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/extensions/runner.d.ts.map +1 -1
  7. package/dist/core/extensions/runner.js +4 -1
  8. package/dist/core/extensions/runner.js.map +1 -1
  9. package/dist/core/keybindings.d.ts +162 -98
  10. package/dist/core/keybindings.d.ts.map +1 -1
  11. package/dist/core/keybindings.js +222 -106
  12. package/dist/core/keybindings.js.map +1 -1
  13. package/dist/modes/interactive/command-executor.d.ts.map +1 -1
  14. package/dist/modes/interactive/command-executor.js +95 -62
  15. package/dist/modes/interactive/command-executor.js.map +1 -1
  16. package/dist/modes/interactive/components/ask-options.d.ts +20 -6
  17. package/dist/modes/interactive/components/ask-options.d.ts.map +1 -1
  18. package/dist/modes/interactive/components/ask-options.js +35 -13
  19. package/dist/modes/interactive/components/ask-options.js.map +1 -1
  20. package/dist/modes/interactive/components/keybinding-hints.d.ts +9 -0
  21. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  22. package/dist/modes/interactive/components/keybinding-hints.js +12 -0
  23. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  24. package/dist/modes/interactive/components/task-panel.d.ts +2 -2
  25. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/task-panel.js +24 -11
  27. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  28. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  29. package/dist/modes/interactive/components/tree-selector.js +29 -12
  30. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  31. package/dist/modes/interactive/interactive-mode.d.ts +63 -8
  32. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  33. package/dist/modes/interactive/interactive-mode.js +189 -90
  34. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  35. package/dist/modes/interactive/model-controller.d.ts +3 -0
  36. package/dist/modes/interactive/model-controller.d.ts.map +1 -1
  37. package/dist/modes/interactive/model-controller.js +1 -1
  38. package/dist/modes/interactive/model-controller.js.map +1 -1
  39. package/docs/keybindings.md +247 -80
  40. package/docs/terminal-setup.md +8 -3
  41. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  42. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  43. package/examples/extensions/sandbox/package.json +1 -1
  44. package/examples/extensions/with-deps/package.json +1 -1
  45. package/package.json +4 -4
@@ -11,6 +11,7 @@ import { isLongRetryDelayError } from "@kolisachint/hoocode-ai";
11
11
  import { Box, CombinedAutocompleteProvider, Container, fuzzyFilter, getCapabilities, hyperlink, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TUI, } from "@kolisachint/hoocode-tui";
12
12
  import { spawnSync } from "child_process";
13
13
  import { APP_NAME, APP_TITLE, VERSION } from "../../config.js";
14
+ import { TASK_TOOL_NAME } from "../../core/agent-frontmatter.js";
14
15
  import { setTerminalOwnedByTui } from "../../core/agent-log.js";
15
16
  import { loadAgentRegistry } from "../../core/agent-registry.js";
16
17
  import { parseSkillBlock } from "../../core/agent-session.js";
@@ -51,7 +52,7 @@ import { CustomEditor } from "./components/custom-editor.js";
51
52
  import { CustomMessageComponent } from "./components/custom-message.js";
52
53
  import { DynamicBorder } from "./components/dynamic-border.js";
53
54
  import { FooterComponent } from "./components/footer.js";
54
- import { keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js";
55
+ import { appKeyLabel, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js";
55
56
  import { renderSessionChip } from "./components/session-chip.js";
56
57
  import { SessionColorSelectorComponent } from "./components/session-color-selector.js";
57
58
  import { SessionSelectorComponent } from "./components/session-selector.js";
@@ -185,6 +186,8 @@ export class InteractiveMode {
185
186
  startupNoticesShown = false;
186
187
  // Status line tracking (for mutating immediately-sequential status updates)
187
188
  lastStatusSpacer = undefined;
189
+ /** Dial reverses already named this session; see showDialStep. */
190
+ dialReverseTaught = new Set();
188
191
  lastStatusText = undefined;
189
192
  // Streaming message tracking
190
193
  streamingComponent = undefined;
@@ -375,7 +378,7 @@ export class InteractiveMode {
375
378
  await this.rebindCurrentSession();
376
379
  });
377
380
  this.version = VERSION;
378
- this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
381
+ this.ui = new TUI(options.terminal ?? new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
379
382
  this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
380
383
  this.headerContainer = new Container();
381
384
  this.chatContainer = new Container();
@@ -461,6 +464,7 @@ export class InteractiveMode {
461
464
  },
462
465
  showSelector: (create) => this.showSelector(create),
463
466
  showStatus: (message) => this.showStatus(message),
467
+ showDialStep: (backward, message) => this.showDialStep(backward, message),
464
468
  showError: (message) => this.showError(message),
465
469
  showWarning: (message) => this.showWarning(message),
466
470
  showNotice: (title, body) => this.showNotice(title, body),
@@ -709,33 +713,53 @@ export class InteractiveMode {
709
713
  : theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
710
714
  // Build startup instructions using keybinding hint helpers
711
715
  const hint = (keybinding, description) => keyHint(keybinding, description);
716
+ // A dial as one line in one shape: the key steps it forward, the same
717
+ // key with shift steps it back. The banner is where that rule is first
718
+ // read, and it is the rule covering the most-pressed keys in the app,
719
+ // so the dials are listed together rather than scattered by topic.
720
+ // appKeyLabel, not keyText: the thinking dial answers to shift+tab as well,
721
+ // and splicing that into the middle of `alt+t/shift+alt+t` would hide the
722
+ // one shape these six lines exist to show.
723
+ const dial = (forward, backward, subject) => rawKeyHint(`${appKeyLabel(forward)}/${appKeyLabel(backward)}`, `to step ${subject}`);
724
+ // Grouped the way the map itself is (see core/keybindings.ts): by what you
725
+ // are trying to do, not by what the widget is. A flat list of thirty
726
+ // hints is a wall nobody reads; five short groups with a heading each is
727
+ // something a person can skim once and place a key in later.
728
+ const group = (title) => theme.fg("dim", `\n${title}`);
712
729
  const expandedInstructions = [
730
+ group("Compose — the message in your hands"),
731
+ hint("app.editor.external", "for external editor"),
732
+ hint("app.input.voiceTranscribe", "to speak instead of type"),
733
+ hint("app.clipboard.pasteImage", "to paste image"),
734
+ hint("app.message.followUp", "to queue follow-up"),
735
+ hint("app.message.dequeue", "to edit all queued messages"),
736
+ rawKeyHint("drop files", "to attach"),
737
+ rawKeyHint("/", "for commands"),
738
+ rawKeyHint("!", "to run bash"),
739
+ rawKeyHint("!!", "to run bash (no context)"),
740
+ group("Steer — what the agent is before it runs"),
741
+ dial("app.mode.cycleForward", "app.mode.cycleBackward", "agent mode (ask/plan/build/debug)"),
742
+ dial("app.model.cycleForward", "app.model.cycleBackward", "model — /model to pick one"),
743
+ dial("app.thinking.cycleForward", "app.thinking.cycleBackward", "thinking level"),
744
+ group("Read — what you see of what it did"),
745
+ dial("app.view.cycleForward", "app.view.cycleBackward", "tool output (radar/peek/full)"),
746
+ dial("app.tasks.cycleForward", "app.tasks.cycleBackward", "task panel view"),
747
+ hint("app.tools.expand", "to jump to full output and back"),
748
+ hint("app.thinking.toggle", "to show or hide thinking"),
749
+ ...(this.teamFocus.connected ? [hint("app.team.focus", "to focus team roster")] : []),
750
+ group("Go — sessions and places"),
751
+ hint("app.session.resume", "to resume a session"),
752
+ hint("app.session.changeDirectory", "to change working directory"),
753
+ dial("app.session.color.cycleForward", "app.session.color.cycleBackward", "session colour"),
754
+ hint("app.settings.open", "for settings"),
755
+ hint("app.hotkeys.open", "for all shortcuts"),
756
+ group("Flow — getting out, getting back"),
713
757
  hint("app.interrupt", "to interrupt"),
714
758
  hint("app.clear", "to clear"),
715
759
  rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
716
760
  hint("app.exit", "to exit (empty)"),
717
761
  hint("app.suspend", "to suspend"),
718
762
  keyHint("tui.editor.deleteToLineEnd", "to delete to end"),
719
- hint("app.thinking.cycle", "to cycle thinking level"),
720
- rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"),
721
- hint("app.model.select", "to select model"),
722
- hint("app.tools.expand", "to jump to full output and back"),
723
- hint("app.view.cycleForward", "to cycle tool output (radar/peek/full)"),
724
- hint("app.thinking.toggle", "to expand thinking"),
725
- hint("app.tasks.cycleView", "to cycle task panel view"),
726
- ...(this.teamFocus.connected ? [hint("app.team.focus", "to focus team roster")] : []),
727
- hint("app.mode.cycle", "to cycle agent mode"),
728
- hint("app.session.changeDirectory", "to change working directory"),
729
- hint("app.settings.open", "for settings"),
730
- hint("app.hotkeys.open", "for all shortcuts"),
731
- hint("app.editor.external", "for external editor"),
732
- rawKeyHint("/", "for commands"),
733
- rawKeyHint("!", "to run bash"),
734
- rawKeyHint("!!", "to run bash (no context)"),
735
- hint("app.message.followUp", "to queue follow-up"),
736
- hint("app.message.dequeue", "to edit all queued messages"),
737
- hint("app.clipboard.pasteImage", "to paste image"),
738
- rawKeyHint("drop files", "to attach"),
739
763
  ].join("\n");
740
764
  const onboarding = theme.fg("dim", `${APP_NAME} can explain its own features and look up its docs. Ask it how to use or extend ${APP_NAME}.`);
741
765
  this.builtInHeader = new ExpandableText(() => logo(), () => `${logo()}\n${expandedInstructions}\n\n${onboarding}`, this.getStartupExpansionState(), 1, 0);
@@ -767,7 +791,7 @@ export class InteractiveMode {
767
791
  setTerminalOwnedByTui(true);
768
792
  this.isInitialized = true;
769
793
  // Initialize extensions first so resources are shown before messages
770
- await this.rebindCurrentSession();
794
+ await this.rebindCurrentSession({ renderResources: true });
771
795
  // Render initial messages AFTER showing loaded resources
772
796
  this.renderInitialMessages();
773
797
  // Set up theme file watcher
@@ -1057,26 +1081,28 @@ export class InteractiveMode {
1057
1081
  this.showExtensionError(error.extensionPath, error.error, error.stack);
1058
1082
  },
1059
1083
  });
1060
- setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
1061
1084
  this.setupAutocompleteProvider();
1062
- const extensionRunner = this.session.extensionRunner;
1063
- this.setupExtensionShortcuts(extensionRunner);
1064
- // The startup path draws the listing here, because it renders messages
1065
- // without clearing. A *rebind* (/new, /resume, /fork) clears the transcript
1066
- // straight afterwards, so `renderCurrentSessionState` draws it again — this
1067
- // call is the one startup keeps.
1068
- this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
1069
- this.showStartupNoticesIfNeeded();
1085
+ this.setupExtensionShortcuts(this.session.extensionRunner);
1070
1086
  }
1087
+ /**
1088
+ * Push the current settings and session onto the chrome: keybindings, footer,
1089
+ * editor, cursor.
1090
+ *
1091
+ * Startup, a session swap (/new, /resume, /fork, /cd) and /reload all rebuild
1092
+ * the same things from disk, so they must all repaint the same way. They used
1093
+ * to do it with two hand-kept copies of this list, and the /reload copy was
1094
+ * missing pieces — a `compaction.enabled` edit left the footer still
1095
+ * promising auto-compaction. One list, called from both paths; the theme is
1096
+ * the second half, in `applySessionTheme`.
1097
+ */
1071
1098
  applyRuntimeSettings() {
1099
+ // Keybindings first: what follows is drawn with the hints they name.
1100
+ this.keybindings.reload();
1072
1101
  this.footer.setSession(this.session);
1073
1102
  this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
1074
1103
  this.footer.setToolOutputView(this.toolOutputView);
1075
- // The startup banner names the cwd, which `/cd` changes under it.
1076
- if (this.builtInHeader instanceof ExpandableText) {
1077
- this.builtInHeader.refresh();
1078
- }
1079
1104
  this.footerDataProvider.setCwd(this.sessionManager.getCwd());
1105
+ this.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(TASK_TOOL_NAME));
1080
1106
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
1081
1107
  this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
1082
1108
  this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@@ -1092,15 +1118,63 @@ export class InteractiveMode {
1092
1118
  this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
1093
1119
  }
1094
1120
  }
1095
- async rebindCurrentSession() {
1121
+ /**
1122
+ * Load the theme the settings name, and rebuild what holds colour baked in.
1123
+ *
1124
+ * Runs after extensions are bound (and after `session.reload()`), because a
1125
+ * `resources_discover` handler can contribute the very theme directory the
1126
+ * name resolves in — resolving it earlier would fall back to dark for anyone
1127
+ * whose theme ships with an extension.
1128
+ *
1129
+ * The banner is rebuilt rather than invalidated: a `Text` holds its string
1130
+ * with the escapes already in it, so invalidating only drops the line cache
1131
+ * and the old theme's colours come straight back. It also names the working
1132
+ * directory, which `/cd` moves.
1133
+ */
1134
+ applySessionTheme() {
1135
+ setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
1136
+ const themeName = this.settingsManager.getTheme();
1137
+ if (themeName) {
1138
+ const themeResult = setTheme(themeName, true);
1139
+ if (!themeResult.success) {
1140
+ this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`);
1141
+ }
1142
+ }
1143
+ const activeHeader = this.chrome.customHeader ?? this.builtInHeader;
1144
+ if (isExpandable(activeHeader)) {
1145
+ activeHeader.setExpanded(this.getStartupExpansionState());
1146
+ }
1147
+ }
1148
+ /**
1149
+ * The half of the chrome refresh that has to wait for extensions to bind:
1150
+ * they can swap the editor out from under us, and the provider count is only
1151
+ * final once their `registerProvider` calls have run.
1152
+ */
1153
+ async finishRuntimeSettings() {
1154
+ await this.modelController.updateAvailableProviderCount();
1155
+ this.updateEditorBorderColor();
1156
+ this.refreshSessionIdentity();
1157
+ }
1158
+ /**
1159
+ * Rebind the mode to whatever session the runtime now holds.
1160
+ *
1161
+ * `renderResources` belongs to startup alone: it renders into a transcript it
1162
+ * never clears, so the listing has to be drawn here. Every other caller
1163
+ * clears the transcript straight afterwards and draws the listing itself
1164
+ * through `renderCurrentSessionState`.
1165
+ */
1166
+ async rebindCurrentSession(options) {
1096
1167
  this.unsubscribe?.();
1097
1168
  this.unsubscribe = undefined;
1098
1169
  this.applyRuntimeSettings();
1099
1170
  await this.bindCurrentSessionExtensions();
1171
+ this.applySessionTheme();
1172
+ if (options?.renderResources) {
1173
+ this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
1174
+ this.showStartupNoticesIfNeeded();
1175
+ }
1100
1176
  this.subscribeToAgent();
1101
- await this.modelController.updateAvailableProviderCount();
1102
- this.updateEditorBorderColor();
1103
- this.refreshSessionIdentity();
1177
+ await this.finishRuntimeSettings();
1104
1178
  }
1105
1179
  async handleFatalRuntimeError(prefix, error) {
1106
1180
  const message = error instanceof Error ? error.message : String(error);
@@ -1110,26 +1184,35 @@ export class InteractiveMode {
1110
1184
  process.exit(1);
1111
1185
  }
1112
1186
  /**
1113
- * Reset the transcript to whatever the (possibly just-swapped) session holds.
1114
- *
1115
- * Every caller /new, /resume, /fork reaches here *after* the runtime has
1116
- * rebound extensions, and rebinding is what renders the loaded-resource
1117
- * listing. Clearing the chat therefore wiped the listing a moment after it was
1118
- * drawn, which is why /reload showed skills, agents and plugins and starting a
1119
- * new session showed nothing. Re-rendering it here is what makes the surface
1120
- * common: one call site, so a session change of any kind reports the same
1121
- * capabilities /reload does.
1187
+ * Drop every view-layer reference into the transcript that is about to be
1188
+ * rebuilt. Shared by the session swaps and by /reload: a component that
1189
+ * survives the clear is detached, so anything still pointing at it (the open
1190
+ * chain the next tool call would join, the block the unfold key peels back)
1191
+ * would be writing to a frame nobody renders.
1122
1192
  */
1123
- renderCurrentSessionState() {
1193
+ resetTranscriptView() {
1124
1194
  this.chatContainer.clear();
1125
1195
  this.openChain = undefined;
1126
1196
  this.latestToolBlock = undefined;
1127
1197
  this.latestChain = undefined;
1128
- this.pendingMessagesContainer.clear();
1129
- this.messageQueue.resetCompactionQueue();
1130
1198
  this.streamingComponent = undefined;
1131
1199
  this.streamingMessage = undefined;
1132
1200
  this.pendingTools.clear();
1201
+ }
1202
+ /**
1203
+ * Reset the transcript to whatever the just-swapped session holds.
1204
+ *
1205
+ * Every caller — /new, /resume, /fork, /cd, /import — reaches here after the
1206
+ * runtime has rebound extensions. Drawing the listing here rather than in the
1207
+ * rebind is what makes the surface common: one call site, so a session change
1208
+ * of any kind reports the same capabilities /reload does, and neither path
1209
+ * draws the listing twice.
1210
+ */
1211
+ renderCurrentSessionState() {
1212
+ this.resetTranscriptView();
1213
+ // Queues belong to the session that is going away, not to the new one.
1214
+ this.pendingMessagesContainer.clear();
1215
+ this.messageQueue.resetCompactionQueue();
1133
1216
  this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
1134
1217
  this.renderInitialMessages();
1135
1218
  }
@@ -1510,7 +1593,8 @@ export class InteractiveMode {
1510
1593
  this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
1511
1594
  this.defaultEditor.onCtrlD = () => this.handleCtrlD();
1512
1595
  this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ());
1513
- this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel());
1596
+ this.defaultEditor.onAction("app.thinking.cycleForward", () => this.cycleThinkingLevel("forward"));
1597
+ this.defaultEditor.onAction("app.thinking.cycleBackward", () => this.cycleThinkingLevel("backward"));
1514
1598
  this.defaultEditor.onAction("app.model.cycleForward", () => this.modelController.cycleModel("forward"));
1515
1599
  this.defaultEditor.onAction("app.model.cycleBackward", () => this.modelController.cycleModel("backward"));
1516
1600
  // Global debug handler on TUI (works regardless of focus)
@@ -1520,8 +1604,11 @@ export class InteractiveMode {
1520
1604
  this.defaultEditor.onAction("app.view.cycleForward", () => this.cycleToolOutputView("forward"));
1521
1605
  this.defaultEditor.onAction("app.view.cycleBackward", () => this.cycleToolOutputView("backward"));
1522
1606
  this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
1523
- this.defaultEditor.onAction("app.tasks.cycleView", () => {
1524
- this.taskPanel.cycleView();
1607
+ this.defaultEditor.onAction("app.tasks.cycleForward", () => {
1608
+ this.taskPanel.cycleView("forward");
1609
+ });
1610
+ this.defaultEditor.onAction("app.tasks.cycleBackward", () => {
1611
+ this.taskPanel.cycleView("backward");
1525
1612
  });
1526
1613
  this.defaultEditor.onAction("app.team.focus", () => this.teamFocus.enterFocus());
1527
1614
  this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
@@ -1542,7 +1629,8 @@ export class InteractiveMode {
1542
1629
  this.defaultEditor.onAction("app.session.color.cycleBackward", () => this.cycleSessionColor("backward"));
1543
1630
  this.defaultEditor.onAction("app.settings.open", () => this.showSettingsSelector());
1544
1631
  this.defaultEditor.onAction("app.hotkeys.open", () => this.commandExecutor.handleHotkeys());
1545
- this.defaultEditor.onAction("app.mode.cycle", () => void this.cycleAgentMode());
1632
+ this.defaultEditor.onAction("app.mode.cycleForward", () => void this.cycleAgentMode("forward"));
1633
+ this.defaultEditor.onAction("app.mode.cycleBackward", () => void this.cycleAgentMode("backward"));
1546
1634
  this.defaultEditor.onChange = (text) => {
1547
1635
  const wasBashMode = this.isBashMode;
1548
1636
  this.isBashMode = text.trimStart().startsWith("!");
@@ -2267,6 +2355,28 @@ export class InteractiveMode {
2267
2355
  * If multiple status messages are emitted back-to-back (without anything else being added to the chat),
2268
2356
  * we update the previous status line instead of appending new ones to avoid log spam.
2269
2357
  */
2358
+ /**
2359
+ * Report a dial step, and name the key that steps it back — once.
2360
+ *
2361
+ * The instant after someone moves a dial is the one moment they are primed to
2362
+ * learn its other half: they have just used one direction and can feel the
2363
+ * missing one. So the reverse rides along with the first step of each dial in
2364
+ * a session and never again. A hint that repeats forever stops being read,
2365
+ * and its cost falls entirely on the people who already know it.
2366
+ *
2367
+ * Only the three dials that announce their new value in the transcript come
2368
+ * through here. The other three announce themselves where they live — the
2369
+ * task panel prints its own key in its header, and the mode chip and view
2370
+ * glyph change in place in the footer.
2371
+ */
2372
+ showDialStep(backward, message) {
2373
+ if (this.dialReverseTaught.has(backward)) {
2374
+ this.showStatus(message);
2375
+ return;
2376
+ }
2377
+ this.dialReverseTaught.add(backward);
2378
+ this.showStatus(theme.fg("dim", message) + theme.fg("halftone", ` ${keyText(backward)} steps back`));
2379
+ }
2270
2380
  showStatus(message) {
2271
2381
  const children = this.chatContainer.children;
2272
2382
  const last = children.length > 0 ? children[children.length - 1] : undefined;
@@ -2650,15 +2760,15 @@ export class InteractiveMode {
2650
2760
  }
2651
2761
  this.ui.requestRender();
2652
2762
  }
2653
- cycleThinkingLevel() {
2654
- const newLevel = this.session.cycleThinkingLevel();
2763
+ cycleThinkingLevel(direction) {
2764
+ const newLevel = this.session.cycleThinkingLevel(direction);
2655
2765
  if (newLevel === undefined) {
2656
2766
  this.showStatus("Current model does not support thinking");
2657
2767
  }
2658
2768
  else {
2659
2769
  this.footer.invalidate();
2660
2770
  this.updateEditorBorderColor();
2661
- this.showStatus(`Thinking level: ${newLevel}`);
2771
+ this.showDialStep("app.thinking.cycleBackward", `Thinking level: ${newLevel}`);
2662
2772
  }
2663
2773
  }
2664
2774
  /**
@@ -2834,7 +2944,7 @@ export class InteractiveMode {
2834
2944
  * is missing (a `--light` run strips the extension), the key says so instead
2835
2945
  * of guessing.
2836
2946
  */
2837
- async cycleAgentMode() {
2947
+ async cycleAgentMode(direction) {
2838
2948
  const command = this.session.extensionRunner.getCommand("mode");
2839
2949
  if (!command) {
2840
2950
  this.showWarning("Modes are not available in this session");
@@ -2847,8 +2957,12 @@ export class InteractiveMode {
2847
2957
  return;
2848
2958
  }
2849
2959
  const current = this.footerDataProvider.getActiveMode();
2960
+ // indexOf is -1 when the active mode is not in the list; stepping from
2961
+ // there lands on the first mode going forward and the last going back,
2962
+ // which is the useful answer either way.
2850
2963
  const index = modes.indexOf(current);
2851
- const next = modes[(index + 1) % modes.length];
2964
+ const step = direction === "forward" ? 1 : -1;
2965
+ const next = modes[(index + step + modes.length) % modes.length];
2852
2966
  await this.session.prompt(`/mode ${next}`);
2853
2967
  }
2854
2968
  /**
@@ -2865,7 +2979,7 @@ export class InteractiveMode {
2865
2979
  const slot = cycleSessionColorSlot(this.sessionManager.getSessionColorSlot(), direction);
2866
2980
  this.session.setSessionColor(slot);
2867
2981
  const name = sessionColorName(slot);
2868
- this.showStatus(`Session color: ${name ?? slot}`);
2982
+ this.showDialStep("app.session.color.cycleBackward", `Session color: ${name ?? slot}`);
2869
2983
  }
2870
2984
  toggleThinkingBlockVisibility() {
2871
2985
  this.hideThinkingBlock = !this.hideThinkingBlock;
@@ -3635,40 +3749,25 @@ export class InteractiveMode {
3635
3749
  };
3636
3750
  try {
3637
3751
  await this.session.reload();
3638
- this.keybindings.reload();
3639
- const activeHeader = this.chrome.customHeader ?? this.builtInHeader;
3640
- if (isExpandable(activeHeader)) {
3641
- activeHeader.setExpanded(this.toolOutputExpanded);
3642
- }
3643
- setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
3644
- this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
3645
- const themeName = this.settingsManager.getTheme();
3646
- const themeResult = themeName ? setTheme(themeName, true) : { success: true };
3647
- if (!themeResult.success) {
3648
- this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`);
3649
- }
3650
- const editorBorder = this.settingsManager.getEditorBorder();
3651
- const editorPaddingX = this.settingsManager.getEditorPaddingX();
3652
- const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
3653
- this.defaultEditor.setBorder(editorBorder);
3654
- this.defaultEditor.setPaddingX(editorPaddingX);
3655
- this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
3656
- if (this.editor !== this.defaultEditor) {
3657
- this.editor.setBorder?.(editorBorder);
3658
- this.editor.setPaddingX?.(editorPaddingX);
3659
- this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
3660
- }
3661
- this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
3662
- this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
3752
+ // The same chrome refresh a session swap runs, in the same order, so the
3753
+ // two paths cannot report different capabilities or settings.
3754
+ this.applyRuntimeSettings();
3755
+ this.applySessionTheme();
3663
3756
  this.setupAutocompleteProvider();
3664
- const runner = this.session.extensionRunner;
3665
- this.setupExtensionShortcuts(runner);
3666
- this.rebuildChatFromMessages();
3757
+ this.setupExtensionShortcuts(this.session.extensionRunner);
3758
+ // The session itself is not replaced, so the transcript is replayed
3759
+ // rather than rebuilt from a new one — but the view state pointing into
3760
+ // it is just as stale, and gets the same reset.
3761
+ this.resetTranscriptView();
3762
+ this.renderSessionContext(this.sessionManager.buildSessionContext());
3667
3763
  dismissReloadBox(this.editor);
3764
+ // Below the replayed transcript, not above it: /reload is asked for to
3765
+ // see what just loaded, and that answer belongs where you are looking.
3668
3766
  this.showLoadedResources({
3669
3767
  force: false,
3670
3768
  showDiagnosticsWhenQuiet: true,
3671
3769
  });
3770
+ await this.finishRuntimeSettings();
3672
3771
  const modelsJsonError = this.session.modelRegistry.getError();
3673
3772
  if (modelsJsonError) {
3674
3773
  this.showError(`models.json error: ${modelsJsonError}`);