mixdog 0.9.4 → 0.9.6

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 (121) hide show
  1. package/README.md +112 -38
  2. package/package.json +4 -1
  3. package/scripts/bench/r5-orchestrated-task.json +7 -0
  4. package/scripts/bench/round-r13-clientmeta.json +464 -0
  5. package/scripts/bench/round-r14-betafeatures.json +466 -0
  6. package/scripts/bench/round-r15-fulldefault.json +462 -0
  7. package/scripts/bench/round-r16-sessionid.json +466 -0
  8. package/scripts/bench/round-r17-wirebytes.json +456 -0
  9. package/scripts/bench/round-r18-prewarm.json +468 -0
  10. package/scripts/bench/round-r19-clean.json +472 -0
  11. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  12. package/scripts/bench/round-r21-delta-retry.json +473 -0
  13. package/scripts/bench/round-r22-full-probe.json +693 -0
  14. package/scripts/bench/round-r23-itemprobe.json +701 -0
  15. package/scripts/bench/round-r24-shapefix.json +677 -0
  16. package/scripts/bench/round-r25-serial.json +464 -0
  17. package/scripts/bench/round-r26-parallel3.json +671 -0
  18. package/scripts/bench/round-r27-parallel10.json +894 -0
  19. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  20. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  21. package/scripts/bench/round-r30-instid.json +253 -0
  22. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  23. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  24. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  25. package/scripts/bench/round-r34-orchestrated.json +120 -0
  26. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  27. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  28. package/scripts/bench-run.mjs +40 -7
  29. package/scripts/internal-comms-bench.mjs +2 -4
  30. package/scripts/model-catalog-audit.mjs +209 -0
  31. package/scripts/model-list-sanitize-test.mjs +37 -0
  32. package/scripts/recall-bench.mjs +76 -13
  33. package/scripts/recall-quality-cases.json +12 -0
  34. package/scripts/tool-smoke.mjs +2 -2
  35. package/src/agents/heavy-worker/AGENT.md +7 -7
  36. package/src/agents/scheduler-task/AGENT.md +2 -3
  37. package/src/agents/webhook-handler/AGENT.md +2 -3
  38. package/src/agents/worker/AGENT.md +9 -10
  39. package/src/app.mjs +12 -1
  40. package/src/headless-role.mjs +7 -1
  41. package/src/mixdog-session-runtime.mjs +15 -14
  42. package/src/output-styles/default.md +14 -18
  43. package/src/output-styles/minimal.md +4 -5
  44. package/src/output-styles/simple.md +7 -8
  45. package/src/rules/agent/00-common.md +6 -5
  46. package/src/rules/agent/30-explorer.md +16 -5
  47. package/src/rules/lead/01-general.md +5 -5
  48. package/src/rules/lead/lead-brief.md +6 -3
  49. package/src/rules/lead/lead-tool.md +6 -4
  50. package/src/rules/shared/01-tool.md +17 -21
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  52. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +19 -0
  53. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +9 -1
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +53 -9
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +23 -10
  57. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +33 -4
  59. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  60. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +93 -0
  61. package/src/runtime/agent/orchestrator/session/loop.mjs +27 -5
  62. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  66. package/src/runtime/channels/backends/discord-gateway.mjs +2 -2
  67. package/src/runtime/channels/backends/discord.mjs +15 -26
  68. package/src/runtime/channels/backends/telegram.mjs +8 -12
  69. package/src/runtime/channels/index.mjs +23 -43
  70. package/src/runtime/channels/lib/backend-dispatch.mjs +5 -3
  71. package/src/runtime/channels/lib/config.mjs +37 -149
  72. package/src/runtime/channels/lib/event-pipeline.mjs +4 -4
  73. package/src/runtime/channels/lib/event-queue.mjs +15 -9
  74. package/src/runtime/channels/lib/inbound-routing.mjs +2 -39
  75. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  76. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  77. package/src/runtime/channels/lib/webhook/deliveries.mjs +3 -2
  78. package/src/runtime/channels/lib/webhook.mjs +8 -47
  79. package/src/runtime/memory/index.mjs +9 -0
  80. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  81. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  82. package/src/runtime/memory/lib/memory-recall-store.mjs +47 -10
  83. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  84. package/src/runtime/memory/lib/query-handlers.mjs +27 -5
  85. package/src/runtime/memory/tool-defs.mjs +2 -2
  86. package/src/runtime/shared/config.mjs +14 -4
  87. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  88. package/src/runtime/shared/schedules-store.mjs +13 -3
  89. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  90. package/src/session-runtime/config-helpers.mjs +45 -10
  91. package/src/session-runtime/quick-model-rows.mjs +46 -11
  92. package/src/session-runtime/quick-search-models.mjs +28 -27
  93. package/src/session-runtime/settings-api.mjs +34 -1
  94. package/src/session-runtime/tool-defs.mjs +1 -1
  95. package/src/session-runtime/workflow.mjs +1 -1
  96. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  97. package/src/standalone/agent-tool.mjs +30 -0
  98. package/src/standalone/channel-admin.mjs +102 -90
  99. package/src/standalone/explore-tool.mjs +39 -10
  100. package/src/tui/App.jsx +131 -28
  101. package/src/tui/app/app-format.mjs +11 -4
  102. package/src/tui/app/channel-pickers.mjs +2 -4
  103. package/src/tui/app/maintenance-pickers.mjs +60 -28
  104. package/src/tui/app/model-options.mjs +5 -1
  105. package/src/tui/app/settings-picker.mjs +0 -1
  106. package/src/tui/app/transcript-window.mjs +8 -2
  107. package/src/tui/app/use-transcript-scroll.mjs +3 -1
  108. package/src/tui/app/use-transcript-window.mjs +19 -1
  109. package/src/tui/components/ConfirmBar.jsx +10 -7
  110. package/src/tui/components/Picker.jsx +32 -11
  111. package/src/tui/components/PromptInput.jsx +10 -1
  112. package/src/tui/components/StatusLine.jsx +6 -3
  113. package/src/tui/dist/index.mjs +441 -293
  114. package/src/tui/engine.mjs +48 -12
  115. package/src/tui/index.jsx +11 -52
  116. package/src/ui/statusline.mjs +10 -0
  117. package/src/workflows/default/WORKFLOW.md +15 -17
  118. package/src/workflows/sequential/WORKFLOW.md +15 -17
  119. package/vendor/ink/build/ink.js +9 -1
  120. package/scripts/recall-usecase-probe.json +0 -6
  121. package/src/runtime/channels/lib/holidays.mjs +0 -138
package/src/tui/App.jsx CHANGED
@@ -218,6 +218,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
218
218
  const [tuiReady, setTuiReady] = useState(false);
219
219
  const exitRequestedRef = useRef(false);
220
220
  const [resizeState, setResizeState] = useState(() => ({ ...terminalSize(stdout), epoch: 0 }));
221
+ const [panelTransitionEpoch, setPanelTransitionEpoch] = useState(0);
221
222
  // Windows Terminal/conhost scrolls the alt-screen (auto-wrap/DECAWM) when the
222
223
  // bottom-right cell is written. WT_SESSION is also set when the UI runs under
223
224
  // a Unix-ish shell hosted by Windows Terminal, where process.platform is not
@@ -477,6 +478,11 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
477
478
  const [slashIndex, setSlashIndex] = useState(0);
478
479
  const [slashDismissedFor, setSlashDismissedFor] = useState('');
479
480
  const slashPaletteRef = useRef({ open: false, count: 0 });
481
+ // Holding Tab can generate key-repeat faster than a workflow switch can
482
+ // settle. Without a prompt-local guard every repeat starts (or rejects) an
483
+ // async switch and pushes a toast, producing a rapid bottom-layout repaint
484
+ // storm that can visually tear the prompt box in Windows Terminal.
485
+ const workflowTabCycleRef = useRef({ pending: false, lastAt: 0 });
480
486
  const scrollFocusRef = useRef({});
481
487
  const onboardingStartedRef = useRef(false);
482
488
  const onboardingRef = useRef({ defaultRoute: null, searchRoute: null, agentRoutes: {}, agents: [], providerModels: [] });
@@ -671,6 +677,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
671
677
  // extendSelection). Null ⇔ ordinary char-drag selection.
672
678
  const dragRef = useRef({ anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region: null, anchorSpan: null });
673
679
  const transcriptViewportRef = useRef({ top: 0, bottom: 0 });
680
+ const panelTransitionRef = useRef({ signature: '', reserve: 0, clearRows: 0, guardRows: 0, epoch: 0 });
674
681
  // [mixdog] Latest terminal row count + the statusline band (bottom rows),
675
682
  // refreshed each render. The mouse handler uses these to (a) clip a status-bar
676
683
  // grid selection to the statusline rows and (b) route a press to the right
@@ -1249,10 +1256,22 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1249
1256
  toggleExpand();
1250
1257
  return;
1251
1258
  }
1259
+ const rawShiftUp = input === '\x1b[1;2A' || input === '\x1b[a' || input === '[1;2A';
1260
+ const rawShiftDown = input === '\x1b[1;2B' || input === '\x1b[b' || input === '[1;2B';
1261
+ const rawShiftRight = input === '\x1b[1;2C' || input === '\x1b[c' || input === '[1;2C';
1262
+ const rawShiftLeft = input === '\x1b[1;2D' || input === '\x1b[d' || input === '[1;2D';
1263
+ const rawCtrlShiftUp = input === '\x1b[1;6A' || input === '[1;6A';
1264
+ const rawCtrlShiftDown = input === '\x1b[1;6B' || input === '[1;6B';
1265
+ const rawCtrlShiftRight = input === '\x1b[1;6C' || input === '[1;6C';
1266
+ const rawCtrlShiftLeft = input === '\x1b[1;6D' || input === '[1;6D';
1267
+ const rawModifiedShiftArrow = rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight
1268
+ || rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight;
1252
1269
  if (
1253
1270
  !picker
1254
- && key.shift
1255
- && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end)
1271
+ && (
1272
+ (key.shift && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end))
1273
+ || rawModifiedShiftArrow
1274
+ )
1256
1275
  ) {
1257
1276
  // Consume the chord whenever a transcript/status ink-grid selection is
1258
1277
  // live — even if the focus clamps at an edge (moveSelectionFocus returns
@@ -1260,10 +1279,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1260
1279
  // shared gridSelectionActiveRef predicate, so there is no double-handling.
1261
1280
  // When no grid selection is live, fall through to PromptInput.
1262
1281
  let move = null;
1263
- if (key.leftArrow) move = 'left';
1264
- else if (key.rightArrow) move = 'right';
1265
- else if (key.upArrow) move = 'up';
1266
- else if (key.downArrow) move = 'down';
1282
+ if (key.leftArrow || rawShiftLeft || rawCtrlShiftLeft) move = 'left';
1283
+ else if (key.rightArrow || rawShiftRight || rawCtrlShiftRight) move = 'right';
1284
+ else if (key.upArrow || rawShiftUp || rawCtrlShiftUp) move = 'up';
1285
+ else if (key.downArrow || rawShiftDown || rawCtrlShiftDown) move = 'down';
1267
1286
  else if (key.home) move = 'lineStart';
1268
1287
  else if (key.end) move = 'lineEnd';
1269
1288
  if (move && gridSelectionActiveRef.current()) {
@@ -1733,21 +1752,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1733
1752
  }
1734
1753
  const parts = commandText.split('|').map((part) => part.trim());
1735
1754
  if (channelPrompt.kind === 'channel-add') {
1736
- // ID-only input: everything else defaults to the main interactive
1737
- // channel. Legacy `name | id | mode | main` pipe input still parses
1738
- // so nothing breaks, but the UI only asks for the ID.
1739
- const [first, pipeId, mode, mainFlag] = parts;
1755
+ // Single-channel: the UI asks only for the channel id. Legacy
1756
+ // `name | id | ...` pipe input still parses (the id is the second
1757
+ // field) so old muscle memory does not break.
1740
1758
  const isPipe = parts.length > 1;
1741
- const name = isPipe ? first : 'main';
1742
- const channelId = isPipe ? pipeId : first;
1743
- const main = !isPipe
1744
- || String(mainFlag || '').toLowerCase() === 'main'
1745
- || String(name || '').toLowerCase() === 'main';
1746
- store.saveChannel({
1747
- name,
1759
+ const channelId = isPipe ? parts[1] : parts[0];
1760
+ store.setChannel({
1748
1761
  channelId,
1749
- mode: isPipe ? mode : 'interactive',
1750
- main,
1751
1762
  backend: channelPrompt.backend,
1752
1763
  });
1753
1764
  resumeAfterChannelPrompt(channelPrompt);
@@ -1876,6 +1887,25 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1876
1887
  void openSettingsPicker();
1877
1888
  return true;
1878
1889
  }
1890
+ if (settingsPrompt.kind === 'autoclear-provider') {
1891
+ const provider = String(settingsPrompt.provider || '').trim();
1892
+ if (!provider) {
1893
+ store.pushNotice('auto-clear provider is missing', 'warn');
1894
+ return false;
1895
+ }
1896
+ const text = String(commandText || '').trim();
1897
+ try {
1898
+ if (text) store.setAutoClear?.({ provider, duration: text });
1899
+ else store.setAutoClear?.({ provider, resetProvider: true });
1900
+ store.pushNotice(text ? `Auto-clear ${provider} default set to ${text}` : `Auto-clear ${provider} default reset`, 'info');
1901
+ } catch (e) {
1902
+ store.pushNotice(`autoclear failed: ${e?.message || e}`, 'error');
1903
+ return false;
1904
+ }
1905
+ setSettingsPrompt(null);
1906
+ openAutoClearPicker({ advanced: true, returnTo: settingsPrompt.returnTo });
1907
+ return true;
1908
+ }
1879
1909
  if (settingsPrompt.kind === 'profile-title') {
1880
1910
  try {
1881
1911
  store.setProfile?.({ title: commandText });
@@ -2165,6 +2195,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2165
2195
  openProjectPicker();
2166
2196
  } else if (kind === 'core-add' || kind === 'core-edit' || kind === 'core-delete-confirm') {
2167
2197
  openMemoryCorePicker();
2198
+ } else if (kind === 'autoclear-provider') {
2199
+ openAutoClearPicker({ advanced: true, returnTo: settingsPrompt?.returnTo });
2168
2200
  }
2169
2201
  }, [settingsPrompt, showPromptHint]);
2170
2202
 
@@ -2345,6 +2377,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2345
2377
  const hasTextEntryPrompt = !!textEntryPrompt;
2346
2378
  const hasFloatingPanel = !!(toolApproval || picker || contextPanel || usagePanel || slashPaletteOpen || hasTextEntryPrompt);
2347
2379
  const expandedOptionPanel = !!(toolApproval || picker || contextPanel || usagePanel || hasTextEntryPrompt);
2380
+ const bootSettling = !tuiReady && state.items.length === 0 && !hasFloatingPanel;
2348
2381
  // Project selection (initial-entry experience) keeps the welcome banner
2349
2382
  // visible above the picker / path-entry prompt, unlike other floating panels.
2350
2383
  const projectSelectionActive = picker?.kind === 'project'
@@ -2353,10 +2386,15 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2353
2386
  || settingsPrompt?.kind === 'project-rename';
2354
2387
  // Slash search floats above the normal prompt. Actual option panels own the
2355
2388
  // prompt/status area, so they hide those rows and expand into that space.
2356
- const inputBoxHidden = expandedOptionPanel;
2389
+ const inputBoxHidden = expandedOptionPanel || bootSettling;
2357
2390
  const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive || onboardingActive;
2358
2391
  const WELCOME_ROWS = showWelcomeBanner ? 11 : 0;
2359
2392
  const liveSpinner = state.spinner?.active ? state.spinner : (state.commandStatus?.active ? state.commandStatus : null);
2393
+ // Command-status spinner (auto-clear/compact/etc.) is NOT part of the
2394
+ // spinner → TurnDone handoff: it typically starts while the transcript tail
2395
+ // is already a done row (idle session), so the done-at-tail suppression
2396
+ // below must never hide it — that read as a frozen UI during auto-clear.
2397
+ const liveSpinnerIsCommand = !state.spinner?.active && !!state.commandStatus?.active;
2360
2398
  const latestToast = state.toasts?.length ? state.toasts[state.toasts.length - 1] : null;
2361
2399
  const toastHint = latestToast ? latestToast.text : '';
2362
2400
  const progressHint = state.progressHint || null;
@@ -2464,14 +2502,48 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2464
2502
  const pickerVisibleRows = picker
2465
2503
  ? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS)
2466
2504
  : PANEL_MAX_VISIBLE;
2467
- const bottomReserve = baseReserve + floatingPanelRows;
2505
+ const rawBottomReserve = baseReserve + floatingPanelRows;
2506
+ const bottomClusterRows = INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows + floatingPanelRows;
2507
+ const panelLayoutSignature = [
2508
+ toolApproval ? 'tool' : '',
2509
+ picker ? `picker:${picker.kind || ''}:${picker.fillAvailable ? 'fill' : 'fit'}` : '',
2510
+ contextPanel ? 'context' : '',
2511
+ usagePanel ? 'usage' : '',
2512
+ slashPaletteOpen ? 'slash' : '',
2513
+ hasTextEntryPrompt ? `text:${textEntryPrompt?.kind || ''}` : '',
2514
+ inputBoxHidden ? 'input-hidden' : 'input-visible',
2515
+ floatingPanelRows,
2516
+ promptBoxRows,
2517
+ promptMetaRows,
2518
+ queuedRows,
2519
+ WELCOME_ROWS,
2520
+ ].join('|');
2521
+ const panelTransition = panelTransitionRef.current;
2522
+ const panelLayoutChanged = Boolean(panelTransition.signature && panelTransition.signature !== panelLayoutSignature);
2523
+ let panelTransitionClearRows = 0;
2524
+ let panelTransitionGuardRows = 0;
2525
+ if (panelLayoutChanged) {
2526
+ // When a tall floating panel closes, Ink may expand the transcript into rows
2527
+ // that still contain panel ink from the previous frame. Keep those rows blank
2528
+ // for exactly one commit, then let the transcript claim them on the next tick.
2529
+ panelTransitionClearRows = Math.max(0, panelTransition.reserve - bottomClusterRows);
2530
+ panelTransitionGuardRows = panelTransitionClearRows > 0 ? 1 : 0;
2531
+ panelTransition.clearRows = panelTransitionClearRows;
2532
+ panelTransition.guardRows = panelTransitionGuardRows;
2533
+ panelTransition.epoch = panelTransitionEpoch;
2534
+ } else if (panelTransition.epoch === panelTransitionEpoch) {
2535
+ panelTransitionClearRows = panelTransition.clearRows || 0;
2536
+ panelTransitionGuardRows = panelTransition.guardRows || 0;
2537
+ }
2538
+ const bottomReserve = rawBottomReserve + panelTransitionClearRows;
2468
2539
  const viewportHeight = Math.max(1, resizeState.rows - bottomReserve);
2469
2540
  // Keep one physical row between the transcript clip and the bottom cluster
2470
2541
  // even when pinned to the live tail. Windows Terminal/conhost can still
2471
2542
  // surface one clipped/off-by-one transcript row below the statusline during
2472
2543
  // rapid tool-card updates; a permanent guard row makes that row blank instead
2473
2544
  // of a tool header/detail.
2474
- const baseGuardRows = viewportHeight > 1 ? 1 : 0;
2545
+ const guardCapacityRows = Math.max(0, viewportHeight - 1);
2546
+ const baseGuardRows = guardCapacityRows > 0 ? 1 : 0;
2475
2547
  // ── Scroll-time overprint guard ───────────────────────────────────────────
2476
2548
  // Wheel/manual scroll pushes the transcript column DOWN via a negative
2477
2549
  // marginBottom (see the viewport render). Under conhost/Windows Terminal the
@@ -2483,7 +2555,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2483
2555
  // viewport is genuinely scrolled up. The extra blank row absorbs the stray
2484
2556
  // paint instead of the statusline. Requires a viewport tall enough to spare
2485
2557
  // the row, and never shrinks below the base guard.
2486
- const transcriptGuardRows = baseGuardRows;
2558
+ const transcriptGuardRows = Math.min(guardCapacityRows, baseGuardRows + panelTransitionGuardRows);
2487
2559
  const transcriptContentHeight = Math.max(1, viewportHeight - transcriptGuardRows);
2488
2560
  // Bottom-follow / pin semantics must NOT widen with the scroll-time guard, or
2489
2561
  // the "pinned to tail" threshold would drift and streaming could freeze a row
@@ -2538,6 +2610,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2538
2610
  && !inputHint
2539
2611
  );
2540
2612
  welcomePromptHintVisibleRef.current = welcomePromptHintVisible;
2613
+ useEffect(() => {
2614
+ const transition = panelTransitionRef.current;
2615
+ const hadTransitionClearance = panelTransitionClearRows > 0 || panelTransitionGuardRows > 0;
2616
+ transition.signature = panelLayoutSignature;
2617
+ transition.reserve = bottomClusterRows;
2618
+ transition.clearRows = 0;
2619
+ transition.guardRows = 0;
2620
+ if (!hadTransitionClearance) return undefined;
2621
+ const timer = setTimeout(() => setPanelTransitionEpoch((epoch) => epoch + 1), 0);
2622
+ return () => clearTimeout(timer);
2623
+ }, [panelLayoutSignature, bottomClusterRows, panelTransitionClearRows, panelTransitionGuardRows]);
2541
2624
  // Row-index/window memo chain + measured-height harvest + anchor lock:
2542
2625
  // extracted to app/use-transcript-window.mjs.
2543
2626
  const {
@@ -2578,6 +2661,14 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2578
2661
  });
2579
2662
  const cycleWorkflowFromPrompt = useCallback(() => {
2580
2663
  if (slashPaletteOpen || toolApproval || picker || settingsPrompt || providerPrompt || channelPrompt || hookPrompt || contextPanel || usagePanel) return true;
2664
+ const repeatGuardMs = 300;
2665
+ const cycleGuard = workflowTabCycleRef.current;
2666
+ const now = Date.now();
2667
+ if (state.commandBusy || cycleGuard.pending || now - cycleGuard.lastAt < repeatGuardMs) {
2668
+ cycleGuard.lastAt = now;
2669
+ return true;
2670
+ }
2671
+ cycleGuard.lastAt = now;
2581
2672
  let workflows = [];
2582
2673
  try {
2583
2674
  workflows = store.listWorkflows?.() || [];
@@ -2597,17 +2688,22 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2597
2688
  const activeIndex = workflows.findIndex((item) => item.active);
2598
2689
  const currentIndex = activeIndex >= 0 ? activeIndex : Math.max(0, workflows.findIndex((item) => item.id === workflow.id));
2599
2690
  const next = workflows[(currentIndex + 1 + workflows.length) % workflows.length];
2600
- void store.setWorkflow?.(next.id)
2691
+ cycleGuard.pending = true;
2692
+ void Promise.resolve()
2693
+ .then(() => store.setWorkflow?.(next.id))
2601
2694
  .then((result) => {
2602
2695
  if (!result) {
2603
- store.pushNotice('Workflow switch is already running.', 'warn');
2604
2696
  return;
2605
2697
  }
2606
- store.pushNotice(workflowSwitchNotice(result), 'info');
2698
+ store.pushNotice(workflowSwitchNotice(result), 'info', { ttlMs: 1200 });
2607
2699
  })
2608
- .catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
2700
+ .catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'))
2701
+ .finally(() => {
2702
+ cycleGuard.pending = false;
2703
+ cycleGuard.lastAt = Date.now();
2704
+ });
2609
2705
  return true;
2610
- }, [slashPaletteOpen, toolApproval, picker, settingsPrompt, providerPrompt, channelPrompt, hookPrompt, contextPanel, usagePanel, state.workflow, store]);
2706
+ }, [slashPaletteOpen, toolApproval, picker, settingsPrompt, providerPrompt, channelPrompt, hookPrompt, contextPanel, usagePanel, state.commandBusy, state.workflow, store]);
2611
2707
  // The hardware/IME caret is parked by PromptInput from its OWN measured box
2612
2708
  // position (ink useCursor + useBoxMetrics) — correct now that the transcript
2613
2709
  // is a live column, so the live-frame line count ink relies on is accurate.
@@ -2783,6 +2879,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2783
2879
  can move; overflow is clipped from the top while the panel remains
2784
2880
  bottom-aligned against the prompt. */}
2785
2881
  <Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={surfaceBackground()}>
2882
+ {panelTransitionClearRows > 0 ? (
2883
+ <Box height={panelTransitionClearRows} flexShrink={0} width="100%" overflow="hidden" backgroundColor={surfaceBackground()} />
2884
+ ) : null}
2786
2885
  {floatingPanelRows > 0 ? (
2787
2886
  <Box flexDirection="column" flexShrink={0} height={floatingPanelRows} overflow="hidden" justifyContent="flex-end" backgroundColor={surfaceBackground()}>
2788
2887
  {toolApproval ? (
@@ -2948,6 +3047,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2948
3047
  initialValue={settingsPrompt.initialValue || ''}
2949
3048
  actionLabel={settingsPrompt.kind === 'skill-use'
2950
3049
  ? 'run'
3050
+ : settingsPrompt.kind === 'autoclear-provider'
3051
+ ? 'save'
2951
3052
  : settingsPrompt.kind === 'project-new'
2952
3053
  ? 'open'
2953
3054
  : settingsPrompt.kind === 'project-create-confirm'
@@ -2963,6 +3064,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2963
3064
  : 'save'}
2964
3065
  promptLabel={settingsPrompt.kind === 'skill-use'
2965
3066
  ? 'Command > '
3067
+ : settingsPrompt.kind === 'autoclear-provider'
3068
+ ? 'Duration > '
2966
3069
  : settingsPrompt.kind === 'project-new'
2967
3070
  ? 'Path > '
2968
3071
  : settingsPrompt.kind === 'project-create-confirm'
@@ -45,11 +45,11 @@ export function workflowDisplayName(workflow = {}) {
45
45
  }
46
46
 
47
47
  export function workflowSwitchNotice(workflow = {}) {
48
- return 'Workflow updated · new sessions';
48
+ return 'Workflow saved · new sessions';
49
49
  }
50
50
 
51
51
  export function modelSwitchNotice() {
52
- return 'Model updated · new sessions';
52
+ return 'Model saved · new sessions';
53
53
  }
54
54
 
55
55
  export function compactJson(value, max = 180) {
@@ -122,13 +122,20 @@ export function formatSessionMessageCount(count) {
122
122
  export function fitLine(value, columns, reserve = 4) {
123
123
  const text = String(value || '');
124
124
  const width = Math.max(1, Number(columns || 80) - reserve);
125
- return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text;
125
+ if (stringWidth(text) <= width) return text;
126
+ if (width <= 1) return '…'.repeat(Math.max(0, width));
127
+ let out = '';
128
+ for (const ch of text) {
129
+ if (stringWidth(`${out}${ch}…`) > width) break;
130
+ out += ch;
131
+ }
132
+ return `${out}…`;
126
133
  }
127
134
 
128
135
  export function centerLine(value, columns, reserve = 0) {
129
136
  const text = fitLine(value, columns, reserve);
130
137
  const width = Math.max(1, Number(columns || 80) - reserve);
131
- const pad = Math.max(0, Math.floor((width - text.length) / 2));
138
+ const pad = Math.max(0, Math.floor((width - stringWidth(text)) / 2));
132
139
  return `${' '.repeat(pad)}${text}`;
133
140
  }
134
141
 
@@ -40,8 +40,7 @@ export function createChannelPickers({
40
40
  const tokenDescription = isTelegram
41
41
  ? `${setup.telegram?.status ?? 'Off'}${setup.telegram?.problem ? ' · Invalid' : ''}`
42
42
  : `${setup.discord.status}${setup.discord.problem ? ' · Invalid' : ''}`;
43
- const mainEntry = (setup.channels || []).find((ch) => ch.main)
44
- || (setup.channels || []).find((ch) => ch.name === 'main');
43
+ const mainEntry = setup.channel || {};
45
44
  const mainTarget = isTelegram
46
45
  ? (mainEntry?.telegramChatId || (activeBackend === 'telegram' ? mainEntry?.channelId : ''))
47
46
  : (mainEntry?.discordChannelId || (activeBackend === 'discord' ? mainEntry?.channelId : ''));
@@ -133,8 +132,7 @@ export function createChannelPickers({
133
132
  return;
134
133
  }
135
134
  const activeBackend = setup.backend || 'discord';
136
- const mainEntry = (setup.channels || []).find((ch) => ch.main)
137
- || (setup.channels || []).find((ch) => ch.name === 'main');
135
+ const mainEntry = setup.channel || {};
138
136
  const typeDescription = (backend) => {
139
137
  const selected = activeBackend === backend;
140
138
  const hasToken = backend === 'telegram'
@@ -140,14 +140,13 @@ export function createMaintenancePickers({
140
140
  // Lead BP4 messages cache TTL follows autoClear (cache-strategy.mjs
141
141
  // resolveLeadMessagesTtl): off or idle>=1h -> 1h, shorter idle -> 5m.
142
142
  const HOUR_MS = 60 * 60 * 1000;
143
- const IDLE_WINDOWS = [
144
- { value: '5m', label: '5m', ms: 5 * 60 * 1000 },
145
- { value: '10m', label: '10m', ms: 10 * 60 * 1000 },
146
- { value: '30m', label: '30m', ms: 30 * 60 * 1000 },
147
- { value: '1h', label: '1h', ms: HOUR_MS },
148
- { value: '6h', label: '6h', ms: 6 * HOUR_MS },
149
- { value: '24h', label: '24h', ms: 24 * HOUR_MS },
150
- ];
143
+ const formatDurationInput = (ms) => {
144
+ const value = Math.max(0, Math.round(Number(ms) || 0));
145
+ if (value > 0 && value % HOUR_MS === 0) return `${value / HOUR_MS}h`;
146
+ if (value > 0 && value % 60_000 === 0) return `${value / 60_000}m`;
147
+ if (value > 0 && value % 1000 === 0) return `${value / 1000}s`;
148
+ return `${value}ms`;
149
+ };
151
150
  const readCurrent = () => {
152
151
  try { return store.getAutoClear?.() || null; } catch { return null; }
153
152
  };
@@ -164,6 +163,49 @@ export function createMaintenancePickers({
164
163
  }
165
164
  render();
166
165
  };
166
+ const openProviderDurationEditor = (entry) => {
167
+ if (!entry?.provider) return;
168
+ setPicker(null);
169
+ setSettingsPrompt({
170
+ kind: 'autoclear-provider',
171
+ label: `Auto-clear · ${entry.provider}`,
172
+ hint: `Type a duration like 10m, 1h, or 24h. Empty resets to built-in ${formatDurationInput(entry.builtInMs)}.`,
173
+ initialValue: formatDurationInput(entry.idleMs),
174
+ provider: entry.provider,
175
+ builtInMs: entry.builtInMs,
176
+ returnTo,
177
+ });
178
+ };
179
+ const renderAdvanced = () => {
180
+ const current = readCurrent();
181
+ const provider = current?.provider || 'default';
182
+ const providerDefaults = Array.isArray(current?.providerDefaults) ? current.providerDefaults : [];
183
+ const items = providerDefaults.map((entry) => ({
184
+ value: `provider:${entry.provider}`,
185
+ label: entry.provider,
186
+ marker: entry.provider === provider ? '✓' : '',
187
+ markerColor: theme.success,
188
+ meta: `${formatDuration(entry.idleMs)}${entry.custom ? ' custom' : ''}`,
189
+ description: `Default idle window for ${entry.provider}. Enter to edit as text.`,
190
+ _action: 'provider-default',
191
+ _entry: entry,
192
+ }));
193
+ setPicker({
194
+ title: 'Auto-clear · Advanced',
195
+ description: 'Provider default idle windows. Enter edits the duration text.',
196
+ help: '↑/↓ Select · Enter Edit · Esc Back',
197
+ indexMode: 'always',
198
+ labelWidth: 18,
199
+ metaWidth: 18,
200
+ items,
201
+ onSelect: (_value, item) => {
202
+ if (item?._action === 'provider-default') openProviderDurationEditor(item._entry);
203
+ },
204
+ onCancel: () => {
205
+ render();
206
+ },
207
+ });
208
+ };
167
209
  const render = () => {
168
210
  const current = readCurrent();
169
211
  const enabled = current?.enabled !== false;
@@ -182,28 +224,19 @@ export function createMaintenancePickers({
182
224
  _action: 'toggle',
183
225
  },
184
226
  {
185
- value: 'default',
186
- label: 'Default',
227
+ value: 'advanced',
228
+ label: 'Advanced',
187
229
  marker: !custom ? '✓' : '',
188
230
  markerColor: theme.success,
189
- meta: `Provider default · ${formatDuration(providerDefault)}`,
190
- description: `Uses the ${current?.provider || 'current'} provider default idle window.`,
191
- _action: 'default',
231
+ meta: `${current?.provider || 'current'} · ${formatDuration(providerDefault)}`,
232
+ description: 'Edit provider-paired default idle windows as text.',
233
+ _action: 'advanced',
192
234
  },
193
- ...IDLE_WINDOWS.map((win) => ({
194
- value: win.value,
195
- label: win.label,
196
- marker: custom && idleMs === win.ms ? '✓' : '',
197
- markerColor: theme.success,
198
- description: `Clear idle sessions after ${win.label}.`,
199
- _action: 'window',
200
- _idleMs: win.ms,
201
- })),
202
235
  ];
203
236
  setPicker({
204
237
  title: 'Auto-clear',
205
238
  description: `Clear idle context after ${enabled ? formatDuration(idleMs) : 'never'} · lead cache TTL ${cacheTtlLabel}.`,
206
- help: '↑/↓ Select · ←/→ Toggle On/Off · Enter Apply · Esc Close',
239
+ help: '↑/↓ Select · ←/→ Toggle On/Off · Enter Open/Toggle · Esc Close',
207
240
  indexMode: 'always',
208
241
  labelWidth: 10,
209
242
  items,
@@ -216,10 +249,8 @@ export function createMaintenancePickers({
216
249
  onSelect: (_value, item) => {
217
250
  if (item?._action === 'toggle') {
218
251
  applyAutoClear({ enabled: !enabled });
219
- } else if (item?._action === 'default') {
220
- applyAutoClear({ reset: true });
221
- } else if (item?._action === 'window') {
222
- applyAutoClear({ enabled: true, idleMs: item._idleMs });
252
+ } else if (item?._action === 'advanced') {
253
+ renderAdvanced();
223
254
  }
224
255
  },
225
256
  onCancel: () => {
@@ -234,7 +265,8 @@ export function createMaintenancePickers({
234
265
  setSettingsPrompt(null);
235
266
  setContextPanel(null);
236
267
  closeUsagePanel();
237
- render();
268
+ if (options.advanced === true) renderAdvanced();
269
+ else render();
238
270
  };
239
271
 
240
272
  const openProfilePicker = (options = {}) => {
@@ -90,6 +90,7 @@ export const modelFamily = (m) => {
90
90
  export const modelContextWindow = (m) => {
91
91
  const raw = Number(m?.contextWindow);
92
92
  const n = Number.isFinite(raw) && raw > 0 ? raw : 0;
93
+ if (n > 0) return n;
93
94
  const provider = String(m?.provider || '').toLowerCase();
94
95
  const id = String(m?.id || '').toLowerCase();
95
96
  const version = parsedModelVersion(id);
@@ -105,7 +106,10 @@ export const formatContextWindow = (tokens) => {
105
106
  if (!Number.isFinite(n) || n <= 0) return '';
106
107
  if (n >= 1_000_000) {
107
108
  const m = n / 1_000_000;
108
- return `${Number.isInteger(m) ? m.toFixed(0) : m.toFixed(1)}M Context`;
109
+ const label = Number.isInteger(m)
110
+ ? m.toFixed(0)
111
+ : m.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
112
+ return `${label}M Context`;
109
113
  }
110
114
  return `${Math.round(n / 1000)}k Context`;
111
115
  };
@@ -441,7 +441,6 @@ export function createSettingsPicker({
441
441
  else if (item?._action === 'workflow') cycleWorkflow(1);
442
442
  },
443
443
  onSelect: (_value, item) => {
444
- setPicker(null);
445
444
  if (item._action === 'autoclear') openAutoClearPicker({ returnTo: openSettingsPicker });
446
445
  else if (item._action === 'profile') openProfilePicker({ returnTo: openSettingsPicker });
447
446
  else if (item._action === 'autocompact') applyCompaction({ auto: !(compaction.auto !== false) });
@@ -562,14 +562,20 @@ function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
562
562
  return rows;
563
563
  }
564
564
 
565
- export function buildTranscriptRowIndex(items, { columns = 80, toolOutputExpanded = false } = {}) {
565
+ export function buildTranscriptRowIndex(items, {
566
+ columns = 80,
567
+ toolOutputExpanded = false,
568
+ suppressMeasuredRowHeights = false,
569
+ } = {}) {
566
570
  const allItems = Array.isArray(items) ? items : [];
567
571
  const rows = new Array(allItems.length);
568
572
  const prefixRows = new Array(allItems.length + 1);
569
573
  prefixRows[0] = 0;
570
574
  for (let i = 0; i < allItems.length; i++) {
571
575
  const item = allItems[i];
572
- const measured = measuredTranscriptRows(item, columns, toolOutputExpanded);
576
+ const measured = suppressMeasuredRowHeights
577
+ ? null
578
+ : measuredTranscriptRows(item, columns, toolOutputExpanded);
573
579
  const rowCount = measured != null
574
580
  ? measured
575
581
  : estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded);
@@ -430,7 +430,9 @@ export function useTranscriptScroll({
430
430
  // top edge, App.jsx ~3060) instead of clamping the selection in
431
431
  // place, then extend the focus onto the newly revealed top row.
432
432
  const beforeTarget = scrollTargetRef.current;
433
- scrollTranscriptRows(1);
433
+ const slack = Math.max(0, Number(transcriptBottomSlackRowsRef.current) || 0);
434
+ const deltaRows = beforeTarget <= slack ? (slack + 1 - beforeTarget) : 1;
435
+ scrollTranscriptRows(deltaRows);
434
436
  if (scrollTargetRef.current !== beforeTarget) {
435
437
  scrolledEdge = true;
436
438
  // scrollTranscriptRows REPLACES dragRef.current with a shifted
@@ -122,11 +122,27 @@ export function useTranscriptWindow({
122
122
  () => transcriptStructureSignature(items, frameColumns, toolOutputExpanded),
123
123
  [items, frameColumns, toolOutputExpanded],
124
124
  );
125
+ const transcriptStreamingActive = (items || []).some(
126
+ (item) => item?.kind === 'assistant' && item?.streaming,
127
+ );
128
+ const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
129
+ const transcriptPinnedForStreaming = followingRef.current
130
+ || scrolledUpRowsForPin <= transcriptBottomSlackRows;
131
+ const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > transcriptBottomSlackRows;
132
+ const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
133
+ const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
134
+ || transcriptAnchorDirtyRef.current;
135
+ const suppressMeasuredRowHeights = transcriptStreamingActive && (
136
+ transcriptPinnedForStreaming
137
+ || (scrolledUpForStreamingMeasure && hasStreamingReadingAnchor)
138
+ || (scrolledUpForStreamingMeasure && prevEstimateGeometry && !transcriptPinnedForStreaming)
139
+ );
125
140
  const transcriptRowIndex = useMemo(() => buildTranscriptRowIndex(items, {
126
141
  columns: frameColumns,
127
142
  toolOutputExpanded,
143
+ suppressMeasuredRowHeights,
128
144
  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig captures the relevant item changes; measuredRowsVersion folds in app-level measured height corrections
129
- }), [transcriptStructureSig, measuredRowsVersion]);
145
+ }), [transcriptStructureSig, measuredRowsVersion, suppressMeasuredRowHeights]);
130
146
  // ── Same-frame anchor lock ───────────────────────────────────────────────
131
147
  // While the user reads older transcript (anchor captured, not dirty), resolve
132
148
  // the scroll offset that keeps the anchored viewport-top row fixed for THIS
@@ -304,6 +320,7 @@ export function useTranscriptWindow({
304
320
  // capture matches what the user saw rather than the stale scrollOffset
305
321
  // state. Bottom-relative, matching transcriptRenderWindow's window math.
306
322
  renderOffset: Math.max(0, Number(renderScrollOffset) || 0),
323
+ suppressMeasuredRowHeights,
307
324
  };
308
325
  // The window memo is keyed on a structure signature that intentionally
309
326
  // ignores per-character growth of the streaming assistant text, so its
@@ -356,6 +373,7 @@ export function useTranscriptWindow({
356
373
  // overhead on a tall transcript. The cached measurements stay authoritative
357
374
  // for the row-index math; a single re-measure is forced on release below.
358
375
  if (dragRef.current.active) return;
376
+ if (transcriptStreamingActive && transcriptTailPinned) return;
359
377
  const els = transcriptItemElsRef.current;
360
378
  if (!els || els.size === 0) return;
361
379
  const liveItems = transcriptMeasureItemsRef.current;
@@ -26,20 +26,23 @@ export function ConfirmBar({ buttons = [], focusedIndex = -1 }) {
26
26
  const list = Array.isArray(buttons) ? buttons.filter(Boolean) : [];
27
27
  if (list.length === 0) return null;
28
28
  return (
29
- <Box flexDirection="row" width="100%" justifyContent="flex-start">
29
+ <Box flexDirection="row" flexShrink={0} justifyContent="flex-start" alignItems="center">
30
30
  {list.map((button, index) => {
31
31
  const isFocused = index === focusedIndex;
32
32
  return (
33
- <Text key={button.value ?? index}>
34
- {index > 0 ? <Text color={theme.subtle}>{' '}</Text> : null}
33
+ <Box
34
+ key={button.value ?? index}
35
+ marginLeft={index > 0 ? 1 : 0}
36
+ paddingX={1}
37
+ backgroundColor={isFocused ? theme.selectionBackground : undefined}
38
+ >
35
39
  <Text
36
- color={isFocused ? theme.selectionText : theme.text}
37
- backgroundColor={isFocused ? theme.selectionBackground : undefined}
40
+ color={isFocused ? theme.selectionText : theme.subtle}
38
41
  bold={isFocused}
39
42
  >
40
- {` ${button.label} `}
43
+ {`[ ${button.label} ]`}
41
44
  </Text>
42
- </Text>
45
+ </Box>
43
46
  );
44
47
  })}
45
48
  </Box>