@zergai/ztc 0.2.6-dev.36101352252 → 0.2.7-dev.36236183737

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 (112) hide show
  1. package/README.md +43 -17
  2. package/bin/zmux-darwin-arm64 +0 -0
  3. package/bin/ztc-tui-darwin-arm64 +0 -0
  4. package/bin/zterm-darwin-arm64 +0 -0
  5. package/dist/App.js +19 -5
  6. package/dist/agent/agent.d.ts +5 -1
  7. package/dist/agent/agent.js +51 -8
  8. package/dist/agent/backends/account_pool.d.ts +5 -6
  9. package/dist/agent/backends/account_pool.js +39 -14
  10. package/dist/agent/backends/account_rotation.d.ts +3 -0
  11. package/dist/agent/backends/account_rotation.js +14 -3
  12. package/dist/agent/backends/codex_account_status.d.ts +42 -0
  13. package/dist/agent/backends/codex_account_status.js +196 -0
  14. package/dist/agent/backends/codex_app_server.d.ts +12 -2
  15. package/dist/agent/backends/codex_app_server.js +66 -6
  16. package/dist/agent/backends/index.d.ts +2 -0
  17. package/dist/agent/backends/index.js +13 -5
  18. package/dist/agent/backends/openai.d.ts +2 -2
  19. package/dist/agent/backends/openai.js +4 -3
  20. package/dist/agent/backends/openai_compatible.js +10 -10
  21. package/dist/agent/backends/openai_responses.d.ts +17 -5
  22. package/dist/agent/backends/openai_responses.js +2 -4
  23. package/dist/agent/backends/token_usage.js +1 -1
  24. package/dist/agent/backends/types.d.ts +3 -0
  25. package/dist/agent/commands/accounts.d.ts +7 -1
  26. package/dist/agent/commands/accounts.js +299 -101
  27. package/dist/agent/commands/config.js +11 -3
  28. package/dist/agent/commands/config_profile_switch.d.ts +2 -0
  29. package/dist/agent/commands/config_profile_switch.js +22 -0
  30. package/dist/agent/commands/context.js +4 -3
  31. package/dist/agent/commands/index.js +1 -2
  32. package/dist/agent/commands/input_mode.js +1 -1
  33. package/dist/agent/commands/model.js +7 -2
  34. package/dist/agent/commands/plan.d.ts +12 -0
  35. package/dist/agent/commands/plan.js +39 -28
  36. package/dist/agent/commands/profile.d.ts +1 -1
  37. package/dist/agent/commands/profile.js +34 -257
  38. package/dist/agent/commands/provider_choices.d.ts +12 -1
  39. package/dist/agent/commands/provider_choices.js +29 -2
  40. package/dist/agent/commands/provider_selection.d.ts +3 -0
  41. package/dist/agent/commands/provider_selection.js +63 -0
  42. package/dist/agent/commands/settings.d.ts +2 -0
  43. package/dist/agent/commands/settings.js +655 -50
  44. package/dist/agent/commands/types.d.ts +19 -2
  45. package/dist/agent/tools/ask_user.js +2 -2
  46. package/dist/app/agent_runner.d.ts +1 -1
  47. package/dist/app/agent_runner.js +2 -2
  48. package/dist/app/ask_user_response.d.ts +3 -0
  49. package/dist/app/ask_user_response.js +16 -0
  50. package/dist/app/command_context.js +29 -13
  51. package/dist/app/controller.d.ts +17 -2
  52. package/dist/app/controller.js +24 -1
  53. package/dist/app/selection_completion.d.ts +9 -3
  54. package/dist/app/selection_completion.js +54 -22
  55. package/dist/cli_args.js +10 -1
  56. package/dist/cli_main.js +85 -2
  57. package/dist/cli_ratatui.d.ts +22 -4
  58. package/dist/cli_ratatui.js +421 -146
  59. package/dist/config/defaults.d.ts +6 -0
  60. package/dist/config/defaults.js +20 -0
  61. package/dist/config/profiles.d.ts +9 -0
  62. package/dist/config/profiles.js +87 -0
  63. package/dist/config/types.d.ts +2 -0
  64. package/dist/config.d.ts +7 -0
  65. package/dist/config.js +283 -40
  66. package/dist/headless.js +14 -0
  67. package/dist/panes/model_catalog.d.ts +1 -1
  68. package/dist/panes/model_catalog.js +24 -2
  69. package/dist/panes/ztc_launch.js +6 -0
  70. package/dist/router_auth.js +3 -2
  71. package/dist/session.d.ts +2 -0
  72. package/dist/session.js +3 -0
  73. package/dist/types.d.ts +23 -2
  74. package/dist/ui/views/app.d.ts +3 -1
  75. package/dist/ui/views/app.js +3 -2
  76. package/dist/ui/views/completion_menu.js +6 -6
  77. package/dist/ui/views/input_area.d.ts +2 -1
  78. package/dist/ui/views/input_area.js +9 -8
  79. package/dist/ui/views/status_bar.d.ts +2 -0
  80. package/dist/ui/views/status_bar.js +15 -4
  81. package/dist/ui/widgets/color_panel.d.ts +9 -0
  82. package/dist/ui/widgets/color_panel.js +41 -26
  83. package/dist/ui/widgets/navigator_panel.d.ts +9 -0
  84. package/dist/ui/widgets/navigator_panel.js +17 -16
  85. package/dist/ui/widgets/panel_frame.d.ts +83 -3
  86. package/dist/ui/widgets/panel_frame.js +107 -9
  87. package/dist/ui/widgets/questionnaire.d.ts +9 -0
  88. package/dist/ui/widgets/questionnaire.js +214 -16
  89. package/dist/ui/widgets/resume_panel.d.ts +9 -0
  90. package/dist/ui/widgets/resume_panel.js +18 -17
  91. package/dist/ui/widgets/rewind_panel.d.ts +9 -0
  92. package/dist/ui/widgets/rewind_panel.js +15 -16
  93. package/dist/ui/widgets/select.d.ts +6 -0
  94. package/dist/ui/widgets/select.js +47 -12
  95. package/dist/ui/widgets/settings_panel.d.ts +75 -0
  96. package/dist/ui/widgets/settings_panel.js +197 -39
  97. package/dist/ui/widgets/status_line_panel.d.ts +9 -0
  98. package/dist/ui/widgets/status_line_panel.js +18 -19
  99. package/dist/ui/widgets/transcript_preview.d.ts +8 -0
  100. package/dist/ui/widgets/transcript_preview.js +10 -0
  101. package/dist/utils/context.js +2 -0
  102. package/dist/utils/fireworks.d.ts +20 -0
  103. package/dist/utils/fireworks.js +23 -0
  104. package/dist/utils/models.d.ts +2 -1
  105. package/dist/utils/models.js +19 -0
  106. package/dist/web/accounts_mirror.d.ts +7 -0
  107. package/dist/web/accounts_mirror.js +59 -7
  108. package/dist/web/mirror_stream.js +19 -5
  109. package/dist/zlm-embed.mjs +5734 -5362
  110. package/package.json +2 -2
  111. package/dist/agent/commands/provider.d.ts +0 -2
  112. package/dist/agent/commands/provider.js +0 -75
@@ -14,6 +14,7 @@ import { join } from 'path';
14
14
  import { execFile, spawn as spawnChild } from 'child_process';
15
15
  import { promisify } from 'util';
16
16
  import { configStore } from './config.js';
17
+ import { withActiveProfileArg } from './config/profiles.js';
17
18
  import { CODEX_REASONING_EFFORTS } from './config/types.js';
18
19
  import { getSupportedCodexReasoningEfforts, isCodexReasoningEffortSupported, unsupportedEffortMessage, } from './utils/models.js';
19
20
  import { getVersion } from './utils/version.js';
@@ -36,7 +37,7 @@ import { runtimePluginRegistry } from './plugin/runtime_registry.js';
36
37
  import { getMirrorCommandUsages, isRichSlashUsage } from './agent/commands/mirror_usages.js';
37
38
  import { createModeContext } from './agent/commands/mode_commands.js';
38
39
  import { goalManager, goalPinTask, detectGoalCompletion, detectGoalBlocked, goalBlockedReason, goalFinalNote, turnProducedText, turnToolOutcomes, shouldAccountFinalTurn, snapshotGoalForTurn } from './agent/commands/goal.js';
39
- import { allocatePlanArtifact, findPlanContent, generatePlanPath, commitPlan, PlanFileCreationError, } from './agent/commands/plan.js';
40
+ import { allocatePlanArtifact, askPlanApproval, findPlanContent, generatePlanPath, commitPlan, PlanFileCreationError, } from './agent/commands/plan.js';
40
41
  import { setTodoUpdateCallback, clearTodoUpdateCallback, setPaneToolsTuiProcess, clearPaneToolsTuiProcess, setPaneToolsOwnId, setPaneToolsExitCallback, setPaneToolsMirrorCallback, setPaneToolsZdeLiteCallback, setPaneToolsPendingLaunchCallback, clearPaneToolsPendingLaunchCallback, setSupervisionManager, clearSupervisionManager, setBusTransport, clearBusTransport, getBusTransport, setEvolutionTuiProcess, clearEvolutionTuiProcess, resetAskUserTurnFlag } from './agent/tools/index.js';
41
42
  import { shutdownLSPManager } from './lsp/index.js';
42
43
  import { zmuxKeymapPayload } from './config/keybindings.js';
@@ -56,6 +57,7 @@ import { completePath } from './utils/path_complete.js';
56
57
  import { togglePlanMode } from './app/modes.js';
57
58
  import { isMeaningfulUserInput } from './app/input_validation.js';
58
59
  import { headlessInteractionPolicy } from './app/headless_interaction.js';
60
+ import { formatAskUserResponse } from './app/ask_user_response.js';
59
61
  import { connectStartupZerg } from './zerg/startup_connect.js';
60
62
  import { showSettingsPage } from './app/settings_page.js';
61
63
  import { readRepoConfig, writeRepoConfig } from './config/repo_config.js';
@@ -180,6 +182,21 @@ const RESUME_INTERRUPT_CONFIRM_MS = 5000;
180
182
  * match `paddedWidgetView` in `ui/views/app.ts`.
181
183
  */
182
184
  const WIDGET_SURFACE_PADDING_X = 1;
185
+ /**
186
+ * Spaces a full-page surface painted before its first frame cell.
187
+ *
188
+ * A panel that caps its frame centres it, so its margin is data the panel
189
+ * produced, not something the host can recompute without duplicating the cap
190
+ * and the split. Reading it off the painted line keeps the two in step: whatever
191
+ * the panel decided is what the wheel undoes.
192
+ */
193
+ function leadingSurfaceMargin(content) {
194
+ if (!content)
195
+ return 0;
196
+ const first = content.split('\n', 1)[0];
197
+ const frameAt = first.search(/[╭╰│]/);
198
+ return frameAt > 0 ? frameAt : 0;
199
+ }
183
200
  /**
184
201
  * Notice that appears in the status line when the user cancels a run. It reports
185
202
  * a run that is already over, so it is feedback rather than state; the
@@ -393,7 +410,10 @@ export class RatatuiApp {
393
410
  nativeDictationModule = null;
394
411
  legacyDictationModule = null;
395
412
  dictationFilterModule = null;
396
- messageQueue = []; // Queue for messages when agent is busy
413
+ /** Busy Enter prompts waiting for the next completed tool batch. */
414
+ pendingSteering = [];
415
+ nativeSteeringInFlight = [];
416
+ nativeSteeringTail = Promise.resolve();
397
417
  pendingBusTrigger = false; // Bus message arrived while agent was busy — trigger runAgent after current run
398
418
  // Queue state lives in the controller; this latch guards single-flight flush.
399
419
  flushingQueue = false;
@@ -482,6 +502,15 @@ export class RatatuiApp {
482
502
  * only the render path knows which layout the frame actually used.
483
503
  */
484
504
  widgetSurfaceOriginY = null;
505
+ /**
506
+ * Columns between the ZTC pane's left edge and the surface's first column.
507
+ *
508
+ * A full-page panel caps its frame and centres it, so the frame no longer
509
+ * starts at the pane's own gutter: the margin is whatever the panel did not
510
+ * spend. Recorded from the surface it just painted, because only the panel
511
+ * knows how wide its frame ended up.
512
+ */
513
+ widgetSurfaceOriginX = 0;
485
514
  /** Last value pushed to zmux's wheel capture, to send only on change. */
486
515
  wheelCaptureActive = false;
487
516
  promptViewer;
@@ -530,6 +559,7 @@ export class RatatuiApp {
530
559
  // Pending widget callbacks from mirror browser (keyed by widget ID)
531
560
  pendingWidgetCallbacks = new Map();
532
561
  widgetIdCounter = 0;
562
+ activeSettingsWidgetId = null;
533
563
  // zmux multiplexer mode
534
564
  muxMode = false;
535
565
  /**
@@ -967,8 +997,7 @@ export class RatatuiApp {
967
997
  if (this.muxMode) {
968
998
  process.env.ZTC_PANE_RELAY = generateRelaySocketPath();
969
999
  }
970
- // Don't set ZTC_TUI_VIEWPORT_HEIGHT — let Rust compute it from terminal rows
971
- // Both sides use min(rows-1, 20) independently.
1000
+ // Don't set ZTC_TUI_VIEWPORT_HEIGHT — let Rust compute it from terminal rows.
972
1001
  // Colony children: use real zmux in headless mode so sub-panes get real
973
1002
  // PTYs (shells run interactively, ZTC sub-panes work properly).
974
1003
  // Parent --headless-mirror keeps HeadlessPaneManager (pure-Node, no PTY)
@@ -1548,6 +1577,101 @@ export class RatatuiApp {
1548
1577
  // Sync mirror superuser state after auth changes (login/logout/status).
1549
1578
  this.mirrorServer?.setSuperuser(configStore.isSuperuser());
1550
1579
  }
1580
+ /** Change the active config while retaining this renderer's session and transcript. */
1581
+ async switchConfigProfileInPlace(name) {
1582
+ if (this.isHostedRuntime()) {
1583
+ throw new Error('Hosted thread settings cannot be replaced by a local config profile.');
1584
+ }
1585
+ const status = this.controller.getState().agentState.status;
1586
+ if (status !== 'idle' && status !== 'error') {
1587
+ throw new Error('Wait for the current turn to finish before switching profiles.');
1588
+ }
1589
+ const previousOverseerOverride = this.overseerModelOverride;
1590
+ const previousConfig = configStore.get();
1591
+ const previousMcp = JSON.stringify(previousConfig.mcpServers ?? {});
1592
+ const previousColonyMcp = process.env.ZTC_COLONY_MCP;
1593
+ const previousColonyOwned = process.env.ZTC_COLONY_MCP_CONFIG_OWNED;
1594
+ let mcpChanged = false;
1595
+ let touchedRuntime = false;
1596
+ const applyColonyOptIn = () => {
1597
+ if (process.env.ZTC_COLONY_MCP_CONFIG_OWNED === '1') {
1598
+ delete process.env.ZTC_COLONY_MCP;
1599
+ delete process.env.ZTC_COLONY_MCP_CONFIG_OWNED;
1600
+ }
1601
+ if (configStore.get().mcp?.colonyAutoAttach && !process.env.ZTC_COLONY_MCP) {
1602
+ process.env.ZTC_COLONY_MCP = '1';
1603
+ process.env.ZTC_COLONY_MCP_CONFIG_OWNED = '1';
1604
+ }
1605
+ };
1606
+ const reloadMcp = async () => {
1607
+ const { closeMcpServers, colonyOptIn } = await import('./mcp/registry.js');
1608
+ await closeMcpServers();
1609
+ const servers = configStore.get().mcpServers;
1610
+ if ((servers && Object.keys(servers).length > 0) || colonyOptIn()) {
1611
+ const { initMcpServers } = await import('./mcp/client.js');
1612
+ await initMcpServers(servers);
1613
+ }
1614
+ };
1615
+ const reloadSkills = async () => {
1616
+ const { invalidateSkillsCache, preloadSkillsManifest } = await import('./skills/registry.js');
1617
+ invalidateSkillsCache();
1618
+ await preloadSkillsManifest();
1619
+ };
1620
+ try {
1621
+ await configStore.switchProfile(name, async () => {
1622
+ touchedRuntime = true;
1623
+ applyColonyOptIn();
1624
+ mcpChanged = previousMcp !== JSON.stringify(configStore.get().mcpServers ?? {})
1625
+ || previousColonyMcp !== process.env.ZTC_COLONY_MCP;
1626
+ if (mcpChanged)
1627
+ await reloadMcp();
1628
+ await reloadSkills();
1629
+ this.overseerModelOverride = null;
1630
+ const nextAgent = this.createAgent();
1631
+ this.replaceAgent(nextAgent);
1632
+ if (nextAgent)
1633
+ await this.awaitAgentBind();
1634
+ this.hookLifecycle.ensureWatcher();
1635
+ if (this.shouldPersistSession())
1636
+ await this.saveCurrentSession(true);
1637
+ });
1638
+ }
1639
+ catch (error) {
1640
+ this.overseerModelOverride = previousOverseerOverride;
1641
+ if (previousColonyMcp === undefined)
1642
+ delete process.env.ZTC_COLONY_MCP;
1643
+ else
1644
+ process.env.ZTC_COLONY_MCP = previousColonyMcp;
1645
+ if (previousColonyOwned === undefined)
1646
+ delete process.env.ZTC_COLONY_MCP_CONFIG_OWNED;
1647
+ else
1648
+ process.env.ZTC_COLONY_MCP_CONFIG_OWNED = previousColonyOwned;
1649
+ try {
1650
+ if (touchedRuntime) {
1651
+ if (mcpChanged)
1652
+ await reloadMcp();
1653
+ await reloadSkills();
1654
+ const restoredAgent = this.createAgent();
1655
+ this.replaceAgent(restoredAgent);
1656
+ if (restoredAgent)
1657
+ await this.awaitAgentBind();
1658
+ }
1659
+ }
1660
+ catch (restoreError) {
1661
+ throw new Error(`Profile switch failed: ${error.message}. Restoring the previous runtime also failed: ${restoreError.message}`);
1662
+ }
1663
+ throw error;
1664
+ }
1665
+ if (this.muxMode)
1666
+ this.tui.send({ type: 'set_keybindings', keymap: zmuxKeymapPayload(configStore.get().keybindings) });
1667
+ this.mirrorServer?.setSfxConfig(configStore.get().sfx ?? { enabled: false });
1668
+ this.mirrorServer?.setSuperuser(configStore.isSuperuser());
1669
+ const { notifyAccountsChanged } = await import('./agent/backends/account_events.js');
1670
+ notifyAccountsChanged();
1671
+ if (!this.agent)
1672
+ this.addSystemMessage('This profile has no usable provider yet. Configure one in /settings.');
1673
+ this.render();
1674
+ }
1551
1675
  /**
1552
1676
  * Environment handed to the renderer so zmux boots with the user's rebinds.
1553
1677
  * Live changes are pushed over the socket afterwards (`set_keybindings`).
@@ -1587,6 +1711,7 @@ export class RatatuiApp {
1587
1711
  // and auto-compact onto the char/4 estimate while the bar stayed real.
1588
1712
  getContextTokens: () => this.controller.getState().contextLength ?? 0,
1589
1713
  getCachedInputTokens: () => this.controller.getState().agentState.cachedInputTokens,
1714
+ getCacheInputTokens: () => this.controller.getState().agentState.cacheInputTokens,
1590
1715
  // The window the bar last used (status.total) — provider-reported (Codex's
1591
1716
  // modelContextWindow) when available, else the static guess. Keeps /context
1592
1717
  // + auto-compact on the SAME denominator the bar shows.
@@ -1594,6 +1719,7 @@ export class RatatuiApp {
1594
1719
  setContextUsage: (used, estimated) => this.publishContextUsage(used, estimated),
1595
1720
  setAgentState: (state) => this.controller.setAgentState(state),
1596
1721
  reloadAgent: (options) => this.reloadAgentInPlace(options?.defer),
1722
+ switchConfigProfile: (name) => this.switchConfigProfileInPlace(name),
1597
1723
  setRuntimeModelOverride: (provider, model) => {
1598
1724
  configStore.setCliOverride('provider', provider);
1599
1725
  configStore.setCliOverride('model', model);
@@ -2044,14 +2170,36 @@ export class RatatuiApp {
2044
2170
  },
2045
2171
  } : {}),
2046
2172
  settingsPanel: {
2173
+ closeSettingsPanel: () => {
2174
+ if (this.settingsPanelWidget.isActive()) {
2175
+ this.settingsPanelWidget.cancel();
2176
+ }
2177
+ else if (this.activeSettingsWidgetId) {
2178
+ this.pendingWidgetCallbacks.delete(this.activeSettingsWidgetId);
2179
+ if (this.mirrorServer)
2180
+ this.mirrorServer.publishWidgetDismiss(this.activeSettingsWidgetId);
2181
+ else if (this.mirrorClient)
2182
+ this.mirrorClient.publishWidgetDismiss(this.activeSettingsWidgetId);
2183
+ this.activeSettingsWidgetId = null;
2184
+ }
2185
+ this.render();
2186
+ },
2047
2187
  showSettingsPanel: (options) => {
2188
+ if (this.activeSettingsWidgetId) {
2189
+ this.pendingWidgetCallbacks.delete(this.activeSettingsWidgetId);
2190
+ if (this.mirrorServer)
2191
+ this.mirrorServer.publishWidgetDismiss(this.activeSettingsWidgetId);
2192
+ else if (this.mirrorClient)
2193
+ this.mirrorClient.publishWidgetDismiss(this.activeSettingsWidgetId);
2194
+ }
2048
2195
  const widgetId = `settings_${++this.widgetIdCounter}`;
2196
+ this.activeSettingsWidgetId = widgetId;
2049
2197
  // The full settings page replaced the widget_settings modal in the
2050
2198
  // web mirror: navigate watching clients to it. A headless child
2051
2199
  // (mux child or colony cell) posts to the PARENT mirror — the
2052
2200
  // colony singleton's page is the colony-wide config surface, its
2053
2201
  // edits landing in the shared config every cell reads.
2054
- showSettingsPage({ title: options.title, initialFilter: options.initialFilter }, {
2202
+ showSettingsPage({ title: options.title, initialFilter: options.initialFilter ?? options.initialCategory?.toLowerCase() }, {
2055
2203
  mirrorServer: this.mirrorServer,
2056
2204
  mirrorClient: this.headlessMirror ? this.mirrorClient : null,
2057
2205
  });
@@ -2059,12 +2207,19 @@ export class RatatuiApp {
2059
2207
  this.pendingWidgetCallbacks.set(widgetId, (answer) => {
2060
2208
  if (answer._closed) {
2061
2209
  this.pendingWidgetCallbacks.delete(widgetId);
2062
- this.settingsPanelWidget.cancel();
2063
- if (this.mirrorServer)
2064
- this.mirrorServer.publishWidgetDismiss(widgetId);
2065
- else if (this.mirrorClient)
2066
- this.mirrorClient.publishWidgetDismiss(widgetId);
2067
- options.onClose?.();
2210
+ if (this.activeSettingsWidgetId === widgetId)
2211
+ this.activeSettingsWidgetId = null;
2212
+ if (this.settingsPanelWidget.isActive()) {
2213
+ // Its onClose owns dismissal and the user callback.
2214
+ this.settingsPanelWidget.cancel();
2215
+ }
2216
+ else {
2217
+ if (this.mirrorServer)
2218
+ this.mirrorServer.publishWidgetDismiss(widgetId);
2219
+ else if (this.mirrorClient)
2220
+ this.mirrorClient.publishWidgetDismiss(widgetId);
2221
+ options.onClose?.();
2222
+ }
2068
2223
  return;
2069
2224
  }
2070
2225
  if (typeof answer.setting === 'string' && typeof answer.value === 'string') {
@@ -2080,10 +2235,16 @@ export class RatatuiApp {
2080
2235
  this.settingsPanelWidget.show({
2081
2236
  title: options.title,
2082
2237
  categories: options.categories,
2238
+ layout: options.layout,
2239
+ notice: options.notice,
2240
+ initialSetting: options.initialSetting,
2241
+ initialCategory: options.initialCategory,
2083
2242
  initialFilter: options.initialFilter,
2084
2243
  onSettingChange: (setting, value) => options.onSettingChange(setting, value),
2085
2244
  onClose: () => {
2086
2245
  this.pendingWidgetCallbacks.delete(widgetId);
2246
+ if (this.activeSettingsWidgetId === widgetId)
2247
+ this.activeSettingsWidgetId = null;
2087
2248
  if (this.mirrorServer)
2088
2249
  this.mirrorServer.publishWidgetDismiss(widgetId);
2089
2250
  options.onClose?.();
@@ -3055,7 +3216,7 @@ export class RatatuiApp {
3055
3216
  debugLog('[start] Calling tui.start()');
3056
3217
  // Start the TUI process
3057
3218
  const desktopUpdateRelaunch = this.desktopMode
3058
- ? buildDesktopUpdateRelaunchContext(process.argv.slice(1), this.sessionId, process.execPath, process.cwd(), process.pid)
3219
+ ? buildDesktopUpdateRelaunchContext(withActiveProfileArg(process.argv.slice(1)), this.sessionId, process.execPath, process.cwd(), process.pid)
3059
3220
  : undefined;
3060
3221
  const size = await this.tui.start({
3061
3222
  mux: this.muxMode,
@@ -3070,6 +3231,7 @@ export class RatatuiApp {
3070
3231
  // rather than zmux scrolling history under a panel it thinks is closed.
3071
3232
  this.wheelCaptureActive = false;
3072
3233
  this.widgetSurfaceOriginY = null;
3234
+ this.widgetSurfaceOriginX = 0;
3073
3235
  const normalized = normalizeTerminalSize(size.cols, size.rows);
3074
3236
  this.cols = normalized.cols;
3075
3237
  this.rows = normalized.rows;
@@ -4736,7 +4898,10 @@ export class RatatuiApp {
4736
4898
  const status = contextStatusFromUsed(used, model, windowOverride);
4737
4899
  this.controller.dispatch({ type: 'SET_CONTEXT', length: used, estimated, status });
4738
4900
  const agentState = this.controller.getState().agentState;
4739
- this.controller.setAgentState({ ...agentState, contextTokens: used, tokensEstimated: estimated });
4901
+ this.controller.setAgentState({
4902
+ ...agentState, contextTokens: used, tokensEstimated: estimated,
4903
+ cacheInputTokens: undefined, cachedInputTokens: undefined,
4904
+ });
4740
4905
  this.mirror?.publishTokenUsage(used, 0, status.total, status.percentage, { reset: true });
4741
4906
  }
4742
4907
  clearTranscript() {
@@ -5237,8 +5402,9 @@ export class RatatuiApp {
5237
5402
  * used to give up a row here anyway, which left one blank line at the bottom
5238
5403
  * of every spawned ZTC pane.
5239
5404
  *
5240
- * Inline keeps rows above the viewport for the shell's scrollback, and desktop
5241
- * is left reserving one because ZTerm's own reservation is unverified.
5405
+ * Inline fills the terminal except for one shell row. The transcript remains
5406
+ * in terminal scrollback when it no longer fits the live view. Desktop is
5407
+ * left reserving one because ZTerm's own reservation is unverified.
5242
5408
  */
5243
5409
  viewportHeightFor(rows, size) {
5244
5410
  if (this.muxMode)
@@ -5247,7 +5413,7 @@ export class RatatuiApp {
5247
5413
  return rows;
5248
5414
  if (this.desktopMode)
5249
5415
  return rows - 1;
5250
- return Math.min(rows - 1, 20);
5416
+ return rows - 1;
5251
5417
  }
5252
5418
  handleTuiEvent(event) {
5253
5419
  debugLog(`[handleTuiEvent] type=${event.type}`);
@@ -5447,7 +5613,7 @@ export class RatatuiApp {
5447
5613
  ? this.inputState.segments[0].text : '';
5448
5614
  if (textCursorIndex(this.inputState.segments, this.inputState.cursor) !== text.length)
5449
5615
  text = '';
5450
- this.selectionCompletion.update(text, configStore.getProvider(), signal => buildModelsController(configStore).list(undefined, { signal }), commands, configStore.getModel());
5616
+ this.selectionCompletion.update(text, configStore.getProvider(), (signal, provider, authMode) => buildModelsController(configStore).list(provider, { signal, authMode }), commands, configStore.getModel());
5451
5617
  return this.selectionCompletion.view();
5452
5618
  }
5453
5619
  handleInput(input, key) {
@@ -5543,8 +5709,7 @@ export class RatatuiApp {
5543
5709
  this.setInputToText(selection.text, selection.text.length);
5544
5710
  else
5545
5711
  this.render();
5546
- if (!selection.submit)
5547
- return;
5712
+ return;
5548
5713
  }
5549
5714
  // Handle Ctrl+C — priority order:
5550
5715
  // 1. If a generation is running, cancel it.
@@ -5951,7 +6116,7 @@ export class RatatuiApp {
5951
6116
  }
5952
6117
  return;
5953
6118
  }
5954
- // Slash/model/provider completion is handled above; Tab here completes paths.
6119
+ // Slash and model completion is handled above; Tab here completes paths.
5955
6120
  if (key.tab && !key.shift) {
5956
6121
  const currentText = getPlainText(this.inputState.segments);
5957
6122
  // @-mention file completion (ZTC-12): repeated Tab cycles candidates.
@@ -6973,27 +7138,35 @@ export class RatatuiApp {
6973
7138
  this.addErrorMessage(buildCredentialSetupMessage());
6974
7139
  return;
6975
7140
  }
6976
- // Check if agent is busy - queue message if in queue mode
7141
+ // Enter during a run steers the current turn at its next tool boundary.
7142
+ // Tab remains the explicit after-turn queue.
6977
7143
  const state = this.controller.getState();
6978
7144
  const busy = state.agentState.status !== 'idle' && state.agentState.status !== 'error';
6979
7145
  const inputMode = state.inputMode || 'queue';
6980
7146
  if (busy) {
6981
7147
  if (inputMode === 'queue') {
6982
- const accepted = this.controller.enqueueMessage({ text, attachments });
6983
- // The queue entry stores only text/attachments, so a queued goal prompt
6984
- // loses its "goal-launched" marker. Drop the pending marker rather than
6985
- // let it leak onto whichever turn the queue starts next.
6986
- this.nextTurnAutomaticGoal = false;
6987
- if (accepted) {
6988
- // Durable audit alongside the live queue panel; mirror publish
6989
- // is explicit because controller.addMessage is terminal-only.
6990
- const breadcrumb = `Queued (${this.controller.getQueue().length})`;
6991
- this.controller.addMessage({ role: 'system', content: breadcrumb });
6992
- this.mirror?.publishMessage('system', breadcrumb);
6993
- }
6994
- else {
6995
- this.controller.setToast(`Queue full (${MAX_QUEUE}). Cancel or wait.`);
7148
+ this.controller.addMessage({ role: 'user', content: text, attachments, metadata: { steering: true } });
7149
+ const message = this.controller.getState().messages.at(-1);
7150
+ if (message) {
7151
+ this.pendingSteering.push(message);
7152
+ if (this.agent) {
7153
+ const activeAgent = this.agent;
7154
+ // Serial requests preserve the submitted order on a provider-owned
7155
+ // thread and in the thread-reuse prefix recorded by that backend.
7156
+ const attempt = this.nativeSteeringTail.then(() => activeAgent.steerUserMessage(message)).then(accepted => {
7157
+ if (accepted) {
7158
+ this.pendingSteering = this.pendingSteering.filter(pending => pending.id !== message.id);
7159
+ }
7160
+ }).catch(() => undefined);
7161
+ this.nativeSteeringTail = attempt;
7162
+ this.nativeSteeringInFlight.push(attempt);
7163
+ }
6996
7164
  }
7165
+ this.nextTurnAutomaticGoal = false;
7166
+ this.mirror?.publishMessage('user', text, attachments.length > 0 ? { attachments } : undefined);
7167
+ this.controller.setToast(this.pendingSteering.length > 1
7168
+ ? `Sending ${this.pendingSteering.length} messages after the current tool finishes`
7169
+ : 'Sending after the current tool finishes');
6997
7170
  return;
6998
7171
  }
6999
7172
  // In interrupt mode, show warning but continue
@@ -7082,6 +7255,11 @@ export class RatatuiApp {
7082
7255
  }
7083
7256
  // Snapshot message count before the run so we can roll back on cancel
7084
7257
  const preRunMessageCount = this.controller.getState().messages.length;
7258
+ // Any steering retained across a prior interrupted or widget-ended turn
7259
+ // is already present in this snapshot. It must not be injected twice.
7260
+ this.pendingSteering = [];
7261
+ this.nativeSteeringInFlight = [];
7262
+ this.nativeSteeringTail = Promise.resolve();
7085
7263
  // Auto-continue and queue flushes re-enter runAgent without going through
7086
7264
  // handleSubmit, so cancel any stale recap here too.
7087
7265
  if (!isRetry) {
@@ -7232,7 +7410,8 @@ export class RatatuiApp {
7232
7410
  // Unreported clears to undefined rather than holding the previous
7233
7411
  // turn's value, which would pair a stale cache count with this
7234
7412
  // turn's input size.
7235
- cachedInputTokens: event.usage.cachedInputTokens,
7413
+ cacheInputTokens: event.cacheInputTokens,
7414
+ cachedInputTokens: event.contextCachedInputTokens,
7236
7415
  });
7237
7416
  this.controller.dispatch({
7238
7417
  type: 'SET_CONTEXT',
@@ -7243,7 +7422,7 @@ export class RatatuiApp {
7243
7422
  // Publish token usage to observer
7244
7423
  this.mirror?.publishTokenUsage(used, event.usage.outputTokens || 0, total, percentage, {
7245
7424
  reset: contextReset,
7246
- cached: event.usage.cachedInputTokens,
7425
+ cached: event.contextCachedInputTokens,
7247
7426
  });
7248
7427
  break;
7249
7428
  }
@@ -7259,7 +7438,15 @@ export class RatatuiApp {
7259
7438
  break;
7260
7439
  }
7261
7440
  }
7262
- }));
7441
+ }, () => this.pendingSteering.splice(0)));
7442
+ if (!cancelled) {
7443
+ while (this.nativeSteeringInFlight.length > 0) {
7444
+ await Promise.allSettled(this.nativeSteeringInFlight.splice(0));
7445
+ }
7446
+ }
7447
+ else {
7448
+ this.nativeSteeringInFlight = [];
7449
+ }
7263
7450
  }
7264
7451
  catch (err) {
7265
7452
  this.flushLocalStreamDelta();
@@ -7283,6 +7470,7 @@ export class RatatuiApp {
7283
7470
  }
7284
7471
  }
7285
7472
  this.addErrorMessage(`Error: ${errorMsg}`);
7473
+ this.movePendingSteeringToEnd();
7286
7474
  this.controller.setAgentState({ status: 'error', error: errorMsg });
7287
7475
  this.stopSpinner();
7288
7476
  this.mirror?.publishStreamEnd();
@@ -7296,6 +7484,8 @@ export class RatatuiApp {
7296
7484
  contextTokens: this.controller.getState().agentState.contextTokens,
7297
7485
  tokensUsed: this.controller.getState().agentState.tokensUsed,
7298
7486
  tokensEstimated: this.controller.getState().agentState.tokensEstimated,
7487
+ cacheInputTokens: this.controller.getState().agentState.cacheInputTokens,
7488
+ cachedInputTokens: this.controller.getState().agentState.cachedInputTokens,
7299
7489
  });
7300
7490
  this.mirror?.publishStatus('idle');
7301
7491
  }, 3000);
@@ -7314,6 +7504,7 @@ export class RatatuiApp {
7314
7504
  // with a correction. Use Esc while idle to rewind to an earlier prompt
7315
7505
  // when the whole exchange should come out of context.
7316
7506
  this.addInterruptedNotice();
7507
+ this.movePendingSteeringToEnd();
7317
7508
  this.controller.setAgentState({
7318
7509
  status: 'idle',
7319
7510
  // Preserve the token accounting across the idle flip — a wholesale
@@ -7322,6 +7513,8 @@ export class RatatuiApp {
7322
7513
  contextTokens: this.controller.getState().agentState.contextTokens,
7323
7514
  tokensUsed: this.controller.getState().agentState.tokensUsed,
7324
7515
  tokensEstimated: this.controller.getState().agentState.tokensEstimated,
7516
+ cacheInputTokens: this.controller.getState().agentState.cacheInputTokens,
7517
+ cachedInputTokens: this.controller.getState().agentState.cachedInputTokens,
7325
7518
  });
7326
7519
  this.stopSpinner();
7327
7520
  this.mirror?.publishStreamEnd();
@@ -7336,70 +7529,113 @@ export class RatatuiApp {
7336
7529
  return;
7337
7530
  }
7338
7531
  if (result) {
7339
- // Add final response if not already streamed
7340
- const state = this.controller.getState();
7341
- const hasStreamedResponse = this.currentRunStreamed;
7342
- // Hoist finalMeta so all branches can use it
7343
- const finalMode = this.controller.getMode();
7344
- const finalMeta = {
7345
- ...(finalMode === 'swarm' ? { conductorMode: true } : {}),
7346
- ...(result.toolCalls.length > 0 ? { toolCallsInTranscript: true } : {}),
7347
- };
7348
- // Strip .result/.error from toolCalls before persisting on the assistant —
7349
- // the explicit tool_result user message (below) carries that data. Keeping
7350
- // both doubles the context size and the token estimate.
7351
- const lightToolCalls = result.toolCalls.map(tc => {
7352
- const { result: _r, error: _e, ...rest } = tc;
7353
- return rest;
7354
- });
7355
- if (!hasStreamedResponse && (result.content || result.toolCalls.length > 0)) {
7356
- this.controller.addMessage({
7357
- role: 'assistant',
7358
- content: result.content || '[Using tools...]',
7359
- toolCalls: lightToolCalls.length > 0 ? lightToolCalls : undefined,
7360
- // Carry the provider's reasoning trace so the next turn replays this
7361
- // assistant message intact; reasoning models expect it back as-is.
7362
- metadata: result.reasoning ? { ...finalMeta, reasoningContent: result.reasoning } : finalMeta,
7363
- });
7364
- if (result.content)
7365
- this.maybeSpeakText(result.content);
7366
- }
7367
- else if (hasStreamedResponse) {
7368
- const lastAssistantIdx = state.messages.reduce((last, m, i) => isPersistableAssistantMessage(m) ? i : last, -1);
7369
- // A streamed turn already has its assistant message in state, so the
7370
- // reasoning trace has to be attached here too — otherwise it is kept only
7371
- // for buffered responses and streamed turns replay a stripped history.
7372
- if (lastAssistantIdx >= 0 && (lightToolCalls.length > 0 || result.reasoning)) {
7373
- const updatedMessages = state.messages.map((m, i) => i === lastAssistantIdx
7374
- ? {
7375
- ...m,
7376
- ...(lightToolCalls.length > 0 ? { toolCalls: lightToolCalls } : {}),
7377
- metadata: { ...m.metadata, ...finalMeta,
7378
- ...(result.reasoning ? { reasoningContent: result.reasoning } : {}),
7379
- },
7380
- }
7381
- : m);
7382
- this.controller.setMessages(updatedMessages);
7383
- }
7384
- }
7385
- // Persist tool_result messages for all completed/errored tool calls.
7386
- // The agent's internal loop pushes these to its local messages array,
7387
- // but that array is detached from the controller state (immutable reducer).
7388
- // Without this, repairConversationHistory injects phantom "interrupted"
7389
- // errors for every tool call, poisoning the context on the next turn.
7390
- if (result.toolCalls.length > 0) {
7391
- const toolResultContent = result.toolCalls
7392
- .filter(tc => tc.status === 'complete' || tc.status === 'error')
7393
- .map(tc => ({
7394
- tool_use_id: tc.id,
7395
- content: tc.error ? `Error: ${tc.error}` : (tc.result || ''),
7532
+ // The agent's ordered tool-use/result/steering messages are the source of
7533
+ // truth for a steered turn. The normal display path collapses all tool
7534
+ // calls onto the final assistant message, which would replay a steering
7535
+ // prompt before the tool result it followed.
7536
+ const turnMessages = result.turnMessages ?? [];
7537
+ const steeredTurn = turnMessages.some(message => message.metadata?.steering === true);
7538
+ if (steeredTurn) {
7539
+ const live = this.controller.getState().messages;
7540
+ // A steering message the turn actually consumed is replayed from
7541
+ // `turnMessages`, so keeping the live copy would duplicate it. One still
7542
+ // in `pendingSteering` never met a tool boundary: dropping it here loses
7543
+ // the prompt from both the transcript and every request. Keep it, and
7544
+ // `movePendingSteeringToEnd` will place it after the answer and take the
7545
+ // follow-up turn.
7546
+ const unconsumedSteering = new Set(this.pendingSteering.map(message => message.id));
7547
+ const retainedMessages = live.slice(preRunMessageCount)
7548
+ .filter(message => message.role === 'system' || message.role === 'tool'
7549
+ || (message.role === 'user'
7550
+ && (message.metadata?.steering !== true || unconsumedSteering.has(message.id))));
7551
+ const orderedMessages = turnMessages.map(message => ({
7552
+ ...message,
7553
+ ...(message.toolCalls ? {
7554
+ toolCalls: message.toolCalls.map(toolCall => {
7555
+ const { result: _result, error: _error, ...lightCall } = toolCall;
7556
+ return lightCall;
7557
+ }),
7558
+ metadata: { ...message.metadata, toolCallsInTranscript: true },
7559
+ } : {}),
7396
7560
  }));
7397
- if (toolResultContent.length > 0) {
7561
+ const finalAssistant = result.content.trim()
7562
+ ? {
7563
+ id: generateId(), role: 'assistant', content: result.content,
7564
+ timestamp: new Date(),
7565
+ ...(result.reasoning ? { metadata: { reasoningContent: result.reasoning } } : {}),
7566
+ }
7567
+ : null;
7568
+ this.controller.setMessages([
7569
+ ...live.slice(0, preRunMessageCount), ...orderedMessages,
7570
+ ...(finalAssistant ? [finalAssistant] : []), ...retainedMessages,
7571
+ ]);
7572
+ }
7573
+ else {
7574
+ // Add final response if not already streamed
7575
+ const state = this.controller.getState();
7576
+ const hasStreamedResponse = this.currentRunStreamed;
7577
+ // Hoist finalMeta so all branches can use it
7578
+ const finalMode = this.controller.getMode();
7579
+ const finalMeta = {
7580
+ ...(finalMode === 'swarm' ? { conductorMode: true } : {}),
7581
+ ...(result.toolCalls.length > 0 ? { toolCallsInTranscript: true } : {}),
7582
+ };
7583
+ // Strip .result/.error from toolCalls before persisting on the assistant —
7584
+ // the explicit tool_result user message (below) carries that data. Keeping
7585
+ // both doubles the context size and the token estimate.
7586
+ const lightToolCalls = result.toolCalls.map(tc => {
7587
+ const { result: _r, error: _e, ...rest } = tc;
7588
+ return rest;
7589
+ });
7590
+ if (!hasStreamedResponse && (result.content || result.toolCalls.length > 0)) {
7398
7591
  this.controller.addMessage({
7399
- role: 'user',
7400
- content: JSON.stringify(toolResultContent),
7401
- metadata: { toolResult: true },
7592
+ role: 'assistant',
7593
+ content: result.content || '[Using tools...]',
7594
+ toolCalls: lightToolCalls.length > 0 ? lightToolCalls : undefined,
7595
+ // Carry the provider's reasoning trace so the next turn replays this
7596
+ // assistant message intact; reasoning models expect it back as-is.
7597
+ metadata: result.reasoning ? { ...finalMeta, reasoningContent: result.reasoning } : finalMeta,
7402
7598
  });
7599
+ if (result.content)
7600
+ this.maybeSpeakText(result.content);
7601
+ }
7602
+ else if (hasStreamedResponse) {
7603
+ const lastAssistantIdx = state.messages.reduce((last, m, i) => isPersistableAssistantMessage(m) ? i : last, -1);
7604
+ // A streamed turn already has its assistant message in state, so the
7605
+ // reasoning trace has to be attached here too — otherwise it is kept only
7606
+ // for buffered responses and streamed turns replay a stripped history.
7607
+ if (lastAssistantIdx >= 0 && (lightToolCalls.length > 0 || result.reasoning)) {
7608
+ const updatedMessages = state.messages.map((m, i) => i === lastAssistantIdx
7609
+ ? {
7610
+ ...m,
7611
+ ...(lightToolCalls.length > 0 ? { toolCalls: lightToolCalls } : {}),
7612
+ metadata: { ...m.metadata, ...finalMeta,
7613
+ ...(result.reasoning ? { reasoningContent: result.reasoning } : {}),
7614
+ },
7615
+ }
7616
+ : m);
7617
+ this.controller.setMessages(updatedMessages);
7618
+ }
7619
+ }
7620
+ // Persist tool_result messages for all completed/errored tool calls.
7621
+ // The agent's internal loop pushes these to its local messages array,
7622
+ // but that array is detached from the controller state (immutable reducer).
7623
+ // Without this, repairConversationHistory injects phantom "interrupted"
7624
+ // errors for every tool call, poisoning the context on the next turn.
7625
+ if (result.toolCalls.length > 0) {
7626
+ const toolResultContent = result.toolCalls
7627
+ .filter(tc => tc.status === 'complete' || tc.status === 'error')
7628
+ .map(tc => ({
7629
+ tool_use_id: tc.id,
7630
+ content: tc.error ? `Error: ${tc.error}` : (tc.result || ''),
7631
+ }));
7632
+ if (toolResultContent.length > 0) {
7633
+ this.controller.addMessage({
7634
+ role: 'user',
7635
+ content: JSON.stringify(toolResultContent),
7636
+ metadata: { toolResult: true },
7637
+ });
7638
+ }
7403
7639
  }
7404
7640
  }
7405
7641
  // Progress text is not completion. Explicit caps must remain visible even
@@ -7441,8 +7677,13 @@ export class RatatuiApp {
7441
7677
  contextTokens: this.controller.getState().agentState.contextTokens,
7442
7678
  tokensUsed: this.controller.getState().agentState.tokensUsed,
7443
7679
  tokensEstimated: this.controller.getState().agentState.tokensEstimated,
7680
+ cacheInputTokens: this.controller.getState().agentState.cacheInputTokens,
7681
+ cachedInputTokens: this.controller.getState().agentState.cachedInputTokens,
7444
7682
  });
7445
7683
  this.stopSpinner();
7684
+ // The idle-edge subscriber sees pending steering and leaves the Tab queue
7685
+ // alone. Move the prompt after the answer before starting its fallback turn.
7686
+ const needsSteeringTurn = this.movePendingSteeringToEnd();
7446
7687
  // Fold in the final turn when the goal went terminal during this turn (the
7447
7688
  // agent ran `/goal complete` / `/goal pause` via ztc_command, which carries no
7448
7689
  // turn facts). This sits above every early return below — ask_user, plan
@@ -7512,9 +7753,24 @@ export class RatatuiApp {
7512
7753
  setTimeout(() => void this.runAgent(), 0);
7513
7754
  return;
7514
7755
  }
7756
+ // A prompt sent during inference had no later tool boundary. Its user
7757
+ // message is already in the transcript; start a fresh turn with it now.
7758
+ if (needsSteeringTurn) {
7759
+ setTimeout(() => void this.runAgent(), 0);
7760
+ return;
7761
+ }
7762
+ // A `subturn` entry that never met a tool boundary (the model answered
7763
+ // without calling one, or the turn ended right after its last call) must not
7764
+ // be stranded behind Tab entries. Promote it now and take the next turn, in
7765
+ // queue order, so Enter's "send it soon" promise still resolves.
7766
+ const strandedSubturn = this.controller.dequeueSubturnMessage();
7515
7767
  this.maybeTriggerRecap(endOfTurnTokens);
7516
7768
  // Flushing is also subscribed to idle transitions in start().
7517
7769
  const processedQueue = this.maybeFlushQueue();
7770
+ if (strandedSubturn) {
7771
+ void this.handleSubmit({ text: strandedSubturn.text, attachments: strandedSubturn.attachments || [] });
7772
+ return;
7773
+ }
7518
7774
  // If nothing the user queued is taking the next turn, let an active /goal
7519
7775
  // drive the next step. Queued user input always wins, so the goal only
7520
7776
  // continues when the queue is empty.
@@ -7678,25 +7934,49 @@ export class RatatuiApp {
7678
7934
  maybeFlushQueue() {
7679
7935
  if (this.flushingQueue)
7680
7936
  return false;
7681
- const state = this.controller.getState();
7682
- if (state.inputMode !== 'queue')
7937
+ if (this.pendingSteering.length > 0)
7683
7938
  return false;
7939
+ const state = this.controller.getState();
7940
+ // Deliberately NOT gated on `inputMode`. `/inputmode interrupt` selects what
7941
+ // Enter does while busy; it must never strand a prompt the user queued with
7942
+ // Tab, which promised delivery at the end of the turn regardless.
7684
7943
  if (state.queue.length === 0)
7685
7944
  return false;
7686
7945
  if (state.agentState.status !== 'idle')
7687
7946
  return false;
7688
7947
  this.flushingQueue = true;
7689
- const next = this.controller.dequeueMessage();
7690
- if (!next) {
7948
+ // End-of-turn entries only. A `subturn` entry still waiting here means no
7949
+ // tool boundary arrived before the turn ended; runAgent's idle path starts a
7950
+ // fresh turn for it, so picking it up here too would double-send it.
7951
+ const index = state.queue.findIndex(entry => entry.delivery !== 'subturn');
7952
+ if (index < 0) {
7691
7953
  this.flushingQueue = false;
7692
7954
  return false;
7693
7955
  }
7956
+ const next = this.controller.getQueue()[index];
7957
+ this.controller.removeQueueEntry(next.id);
7694
7958
  void this.handleSubmit({ text: next.text, attachments: next.attachments || [] }).finally(() => {
7695
7959
  this.flushingQueue = false;
7696
7960
  this.maybeFlushQueue();
7697
7961
  });
7698
7962
  return true;
7699
7963
  }
7964
+ /** Put steering that missed a tool boundary after the finished answer. */
7965
+ movePendingSteeringToEnd() {
7966
+ if (this.pendingSteering.length === 0)
7967
+ return false;
7968
+ const ids = new Set(this.pendingSteering.map(message => message.id));
7969
+ const messages = this.controller.getState().messages;
7970
+ const pending = messages.filter(message => ids.has(message.id));
7971
+ this.pendingSteering = [];
7972
+ if (pending.length === 0)
7973
+ return false;
7974
+ this.controller.setMessages([
7975
+ ...messages.filter(message => !ids.has(message.id)),
7976
+ ...pending,
7977
+ ]);
7978
+ return true;
7979
+ }
7700
7980
  /** True while an agent turn is active (same definition as handleSubmit's busy check). */
7701
7981
  isAgentBusy() {
7702
7982
  const status = this.controller.getState().agentState.status;
@@ -7775,11 +8055,15 @@ export class RatatuiApp {
7775
8055
  const serializedText = serializeSegments(this.inputState.segments);
7776
8056
  if (!isMeaningfulUserInput(serializedText))
7777
8057
  return;
7778
- if (this.controller.enqueueMessage(serializedText)) {
8058
+ // `end-of-turn`: a Tab-queued prompt waits for the WHOLE turn to finish.
8059
+ // Marking the intent on the entry (instead of relying on the busy-state
8060
+ // `inputMode` at flush time) keeps the promise even if the user later runs
8061
+ // /inputmode interrupt, which only governs what Enter does.
8062
+ if (this.controller.enqueueMessage(serializedText, 'end-of-turn')) {
7779
8063
  this.clearComposerAfterSubmit(plainText);
7780
8064
  // Durable audit alongside the live queue panel; mirror publish is
7781
8065
  // explicit because controller.addMessage is terminal-only.
7782
- const breadcrumb = `Queued (${this.controller.getQueue().length})`;
8066
+ const breadcrumb = `Queued (${this.controller.getQueue().length}) — sends when the turn finishes`;
7783
8067
  this.controller.addMessage({ role: 'system', content: breadcrumb });
7784
8068
  this.mirror?.publishMessage('system', breadcrumb);
7785
8069
  }
@@ -8674,39 +8958,28 @@ export class RatatuiApp {
8674
8958
  // the next rebuild and break the streaming-line accounting).
8675
8959
  this.flushTranscriptState();
8676
8960
  const ctx = this.buildCommandContext((msg) => this.controller.addMessage(msg));
8677
- // Show approval select widget
8678
- ctx.select.showSelect({
8679
- title: 'Plan Approval',
8680
- question: 'Ready to begin implementation?',
8681
- type: 'select',
8682
- options: [
8683
- { value: 'approve', label: 'Yes, begin implementation (Recommended)', description: 'Approve the plan, create tasks, and start coding' },
8684
- { value: 'edit', label: 'Edit plan first', description: 'Return to plan mode and continue editing' },
8685
- ],
8686
- allowOther: true,
8687
- }, (value) => {
8688
- if (value === null) {
8961
+ askPlanApproval(ctx, decision => {
8962
+ if (decision.kind === 'cancel') {
8689
8963
  this.controller.addMessage({ role: 'system', content: 'Plan execution cancelled. Still in plan mode.' });
8690
8964
  return;
8691
8965
  }
8692
- const selection = typeof value === 'string' ? value : '';
8693
- if (selection === 'approve') {
8966
+ if (decision.kind === 'approve') {
8694
8967
  this.planApprovedRecently = true;
8695
8968
  commitPlan(planFile, planContent, tasks, ctx, false);
8696
8969
  ctx.triggerAgentTurn?.();
8697
8970
  }
8698
- else if (selection === 'edit') {
8971
+ else if (decision.kind === 'edit') {
8699
8972
  this.controller.addMessage({ role: 'system', content: 'Returning to plan mode. Continue editing your plan, then run /execute when ready.' });
8700
8973
  }
8701
8974
  else {
8702
- this.controller.addMessage({ role: 'system', content: `Feedback noted. Still in plan mode.\n\nFeedback: ${selection}` });
8703
- this.controller.addMessage({ role: 'user', content: `Please update the plan based on this feedback: ${selection}` });
8975
+ this.controller.addMessage({ role: 'system', content: `Feedback noted. Still in plan mode.\n\nFeedback: ${decision.text}` });
8976
+ this.controller.addMessage({ role: 'user', content: `Please update the plan based on this feedback: ${decision.text}` });
8704
8977
  }
8705
8978
  });
8706
8979
  }
8707
8980
  /**
8708
8981
  * Show ask_user widget after ask_user tool is called.
8709
- * Presents the agent's question with predefined options + "Other..." for custom input.
8982
+ * Presents options with an optional note for additional context.
8710
8983
  */
8711
8984
  showAskUserWidget() {
8712
8985
  const data = this.askUserData;
@@ -8769,6 +9042,7 @@ export class RatatuiApp {
8769
9042
  })),
8770
9043
  })),
8771
9044
  allowOther,
9045
+ allowOptionNotes: true,
8772
9046
  callback: (answers) => {
8773
9047
  debugLog(`[askUser multi] callback fired, answers=${JSON.stringify(answers)?.slice(0, 200)}, mutex=${this.askUserAnswerPending}`);
8774
9048
  if (this.askUserAnswerPending)
@@ -8780,20 +9054,15 @@ export class RatatuiApp {
8780
9054
  return;
8781
9055
  }
8782
9056
  if (data.questions.length === 1) {
8783
- const value = answers.q0;
8784
- const answerText = Array.isArray(value) ? value.join(', ') : (value || '');
9057
+ const answerText = formatAskUserResponse(data.questions, answers);
8785
9058
  this.controller.addMessage({ role: 'user', content: answerText });
8786
9059
  this.mirror?.publishMessage('user', answerText, { isAskUserAnswer: true });
8787
9060
  setTimeout(() => void this.runAgent(), 0);
8788
9061
  return;
8789
9062
  }
8790
- // Preserve the existing multi-question wire format.
8791
- const jsonAnswers = data.questions.map((q, i) => {
8792
- const val = answers[`q${i}`];
8793
- const answerStr = Array.isArray(val) ? val.join(', ') : (val || '(no answer)');
8794
- return { question: q.question, answer: answerStr };
8795
- });
8796
- const answerText = JSON.stringify(jsonAnswers);
9063
+ // Preserve the existing multi-question wire format, adding note only
9064
+ // when the user supplied one.
9065
+ const answerText = formatAskUserResponse(data.questions, answers);
8797
9066
  debugLog(`[askUser multi] sending answer: ${answerText.slice(0, 200)}`);
8798
9067
  this.controller.addMessage({ role: 'user', content: answerText });
8799
9068
  this.mirror?.publishMessage('user', answerText, { isAskUserAnswer: true });
@@ -9052,11 +9321,9 @@ export class RatatuiApp {
9052
9321
  keepPrintedInPane: this.muxMode || this.paneMode,
9053
9322
  });
9054
9323
  }
9055
- // Plain inline mode can display terminal scrollback and the live frame at
9056
- // the same time. Once the first-run banner has reached scrollback, remove
9057
- // only that explicitly-marked message from the live frame so it appears
9058
- // exactly once. Mux/pane modes retain their existing fallback because
9059
- // their printed scrollback may not be visible without user navigation.
9324
+ // Once the first-run banner has reached scrollback, remove only that
9325
+ // explicitly-marked message from the live frame so it appears once when
9326
+ // the user scrolls. Mux/pane modes retain their existing fallback.
9060
9327
  if (!externalTranscriptActive && !this.muxMode && !this.paneMode) {
9061
9328
  liveMessages = excludePrintedFirstRunWelcome(liveMessages, this.printedMessageIds);
9062
9329
  }
@@ -9076,15 +9343,15 @@ export class RatatuiApp {
9076
9343
  || this.formWidget.isActive()
9077
9344
  || this.selectWidget.isActive()
9078
9345
  || this.textInputWidget.isActive();
9079
- // The question form is a transient overlay, not a work surface: it sizes to
9080
- // its own content and keeps the recent transcript visible above it.
9081
- const compactWidget = questionnaireActive;
9082
- // Use liveViewportHeight (not effectiveViewportHeight which is
9083
- // computed later + expanded for widgets). scroll_region must match
9084
- // the BASE viewport so it doesn't overflow into the activity_line.
9085
9346
  const agentActive = state.agentState.status === 'thinking'
9086
9347
  || state.agentState.status === 'streaming'
9087
9348
  || state.agentState.status === 'tool_use';
9349
+ const completionView = widgetActive ? null : this.updateSelectionCompletion();
9350
+ // The question form is a transient overlay, not a work surface: it sizes to
9351
+ // its own content and keeps the recent transcript visible above it.
9352
+ const compactWidget = questionnaireActive;
9353
+ // The transcript region must stay above the activity line as the
9354
+ // completion menu expands or contracts.
9088
9355
  // Settings is a full work surface. Unlike transient questions and pickers,
9089
9356
  // it replaces the transcript while open so small terminals still provide
9090
9357
  // enough rows for browsing and editing.
@@ -9094,7 +9361,7 @@ export class RatatuiApp {
9094
9361
  cols: this.cols,
9095
9362
  inputState: this.inputState,
9096
9363
  commands,
9097
- completion: widgetActive ? null : this.updateSelectionCompletion(),
9364
+ completion: completionView,
9098
9365
  showHeader: false,
9099
9366
  showTodos: state.showTodos,
9100
9367
  todoCount: state.todos.length,
@@ -9105,6 +9372,8 @@ export class RatatuiApp {
9105
9372
  agentActive,
9106
9373
  statusLine: configStore.get().statusLine,
9107
9374
  });
9375
+ if (completionView)
9376
+ this.selectionCompletion.setVisibleRows(Math.max(1, widgetLayout.completionHeight - 1));
9108
9377
  // Determine if a widget is active and get its content
9109
9378
  let widgetContent = null;
9110
9379
  let widgetView = null;
@@ -9224,6 +9493,7 @@ export class RatatuiApp {
9224
9493
  model: configStore.get().model,
9225
9494
  emulationId: configStore.getEmulationId(),
9226
9495
  effort: configStore.getCodexEffort(),
9496
+ unsupportedEffort: configStore.getUnsupportedCodexEffort(),
9227
9497
  inputMode: state.inputMode,
9228
9498
  toast: state.toast,
9229
9499
  spinnerLabel: state.spinnerLabel,
@@ -9326,6 +9596,10 @@ export class RatatuiApp {
9326
9596
  this.widgetSurfaceOriginY = widgetContent && !compactWidget
9327
9597
  ? frameLayout.contentAreaY
9328
9598
  : null;
9599
+ // The panel decides its own left edge, so read it off the line it painted
9600
+ // rather than re-deriving it: a panel that capped its frame starts further
9601
+ // right, and the wheel has to land in the same columns the frame occupies.
9602
+ this.widgetSurfaceOriginX = WIDGET_SURFACE_PADDING_X + leadingSurfaceMargin(widgetContent);
9329
9603
  // Tell zmux whether this process is claiming the wheel. While a panel owns
9330
9604
  // it, zmux forwards notches instead of scrolling the transcript history
9331
9605
  // underneath the panel — otherwise closing the panel would reveal a
@@ -9410,7 +9684,7 @@ export class RatatuiApp {
9410
9684
  if (!owner || originY === null)
9411
9685
  return;
9412
9686
  const y = row - originY;
9413
- const x = col - WIDGET_SURFACE_PADDING_X;
9687
+ const x = col - this.widgetSurfaceOriginX;
9414
9688
  if (x < 0 || y < 0)
9415
9689
  return;
9416
9690
  owner.handleMouse({ wheel: direction, x, y });
@@ -10112,7 +10386,7 @@ export class RatatuiApp {
10112
10386
  await this.tui.stop();
10113
10387
  // Build relaunch args: preserve runtime flags but force the target session
10114
10388
  // as the only resume target.
10115
- const { command, args, shell } = buildHotRestartLaunch(process.argv.slice(1), options.sessionId, process.execPath);
10389
+ const { command, args, shell } = buildHotRestartLaunch(withActiveProfileArg(process.argv.slice(1)), options.sessionId, process.execPath);
10116
10390
  const spawnCwd = existsSync(options.cwd) ? options.cwd : process.cwd();
10117
10391
  debugLog(`[${options.label}] Spawning new process: ${command} ${args.join(' ')} (cwd=${spawnCwd})`);
10118
10392
  const child = spawnChild(command, args, {
@@ -10209,6 +10483,7 @@ export class RatatuiApp {
10209
10483
  model: configStore.get().model,
10210
10484
  emulationId: configStore.getEmulationId(),
10211
10485
  effort: configStore.getCodexEffort(),
10486
+ unsupportedEffort: configStore.getUnsupportedCodexEffort(),
10212
10487
  inputMode: state.inputMode,
10213
10488
  toast: state.toast,
10214
10489
  mode: state.mode,