mixdog 0.9.85 → 0.9.87

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 (102) hide show
  1. package/README.md +7 -1
  2. package/package.json +4 -3
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  11. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  12. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  13. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  15. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  16. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  18. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  19. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  20. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  22. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  23. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  24. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  25. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  26. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  27. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  32. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  35. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  37. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  38. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  39. package/src/runtime/agent/orchestrator/tools/token-manifest.json +26 -0
  40. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  41. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  42. package/src/runtime/media/renditions.mjs +1 -1
  43. package/src/runtime/media/store.mjs +1 -1
  44. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  45. package/src/runtime/memory/tool-defs.mjs +19 -3
  46. package/src/runtime/shared/atomic-file.mjs +25 -14
  47. package/src/runtime/shared/automation-attachments.mjs +3 -3
  48. package/src/runtime/shared/child-guardian.mjs +15 -5
  49. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  50. package/src/runtime/shared/resource-admission.mjs +7 -3
  51. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  52. package/src/session-runtime/lifecycle-api.mjs +49 -9
  53. package/src/session-runtime/prewarm.mjs +35 -0
  54. package/src/session-runtime/provider-auth-api.mjs +8 -0
  55. package/src/session-runtime/provider-usage.mjs +30 -5
  56. package/src/session-runtime/remote-control.mjs +8 -47
  57. package/src/session-runtime/remote-transcript.mjs +3 -7
  58. package/src/session-runtime/route-preparation.mjs +24 -0
  59. package/src/session-runtime/runtime-core.mjs +286 -548
  60. package/src/session-runtime/session-lifecycle.mjs +378 -0
  61. package/src/session-runtime/session-turn-api.mjs +9 -2
  62. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  63. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  64. package/src/standalone/agent-shard/shard-pool.mjs +443 -0
  65. package/src/standalone/agent-tool/job-views.mjs +329 -0
  66. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  67. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  68. package/src/standalone/agent-tool.mjs +112 -941
  69. package/src/standalone/channel-daemon-transport.mjs +126 -185
  70. package/src/standalone/channel-daemon.mjs +9 -10
  71. package/src/standalone/usage-dashboard.mjs +23 -1
  72. package/src/tui/App.jsx +362 -2569
  73. package/src/tui/app/app-view.jsx +504 -0
  74. package/src/tui/app/channel-pickers.mjs +3 -8
  75. package/src/tui/app/create-app-pickers.mjs +313 -0
  76. package/src/tui/app/prompt-submit.mjs +501 -0
  77. package/src/tui/app/route-pickers.mjs +117 -152
  78. package/src/tui/app/settings-picker.mjs +90 -89
  79. package/src/tui/app/shell-layout.mjs +563 -0
  80. package/src/tui/app/slash-commands.mjs +7 -7
  81. package/src/tui/app/slash-dispatch.mjs +0 -45
  82. package/src/tui/app/usage-context-panels.mjs +288 -0
  83. package/src/tui/app/use-copy-selection.mjs +56 -0
  84. package/src/tui/app/use-global-key-input.mjs +151 -0
  85. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  86. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  87. package/src/tui/app/use-prompt-hint.mjs +58 -0
  88. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  89. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  90. package/src/tui/app/use-transcript-activity.mjs +131 -0
  91. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  92. package/src/tui/components/StatusLine.jsx +1 -1
  93. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  94. package/src/tui/dist/index.mjs +9857 -9159
  95. package/src/tui/engine/session-api-ext.mjs +47 -9
  96. package/src/tui/engine.mjs +7 -1
  97. package/src/tui/figures.mjs +0 -1
  98. package/src/ui/statusline-format.mjs +0 -1
  99. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  100. package/src/lib/config-cjs.cjs +0 -61
  101. package/src/runtime/shared/launcher-control.mjs +0 -258
  102. package/src/runtime/shared/workspace-router.mjs +0 -259
package/src/tui/App.jsx CHANGED
@@ -33,17 +33,13 @@ import { UsagePanel } from './components/UsagePanel.jsx';
33
33
  import { TextEntryPanel } from './components/TextEntryPanel.jsx';
34
34
  import {
35
35
  buildPromptContentWithImages,
36
- formatImageRef,
36
+ expandPastedTextTokens,
37
37
  imageReferenceIds,
38
+ pastedTextReferenceIds,
38
39
  readClipboardImageAttachment,
39
40
  readImageAttachmentFromPath,
40
- splitPastedImagePathCandidates,
41
- } from './paste-attachments.mjs';
42
- import {
43
- expandPastedTextTokens,
44
- formatPastedTextRef,
45
- pastedTextReferenceIds,
46
41
  shouldFoldPastedText,
42
+ splitPastedImagePathCandidates,
47
43
  } from './paste-attachments.mjs';
48
44
  import { formatDuration } from './time-format.mjs';
49
45
  import {
@@ -91,6 +87,20 @@ import { wrappedTextRows, promptContentRows, wrappedDetailRows, textEntryReserve
91
87
  import stringWidth from 'string-width';
92
88
  import { useMouseInput } from './app/use-mouse-input.mjs';
93
89
  import { useTranscriptScroll } from './app/use-transcript-scroll.mjs';
90
+ import { usePastedBuffers } from './app/use-pasted-buffers.mjs';
91
+ import { usePromptHint } from './app/use-prompt-hint.mjs';
92
+ import { useWelcomePromptHint } from './app/use-welcome-prompt-hint.mjs';
93
+ import { useCopySelection } from './app/use-copy-selection.mjs';
94
+ import { createUsageContextPanels } from './app/usage-context-panels.mjs';
95
+ import { createPromptSubmit } from './app/prompt-submit.mjs';
96
+ import { computeShellLayout } from './app/shell-layout.mjs';
97
+ import { useGlobalKeyInput } from './app/use-global-key-input.mjs';
98
+ import { renderAppView } from './app/app-view.jsx';
99
+ import { usePromptDraftFlow } from './app/use-prompt-draft-flow.mjs';
100
+ import { useTranscriptActivity } from './app/use-transcript-activity.mjs';
101
+ import { createAppPickers } from './app/create-app-pickers.mjs';
102
+ import { usePromptQueueHistory } from './app/use-prompt-queue-history.mjs';
103
+ import { useTerminalChrome } from './app/use-terminal-chrome.mjs';
94
104
  import { useTranscriptWindow } from './app/use-transcript-window.mjs';
95
105
  import {
96
106
  TRANSCRIPT_WINDOW_MIN_ITEMS,
@@ -128,10 +138,6 @@ import {
128
138
  formatSessionMessageCount,
129
139
  fitLine,
130
140
  centerLine,
131
- CONDITIONAL_WELCOME_PROMPT_HINTS,
132
- randomWelcomePromptHint,
133
- providerSetupHasUsableProvider,
134
- activeWorkflowSummaryForStore,
135
141
  promptStatusColor,
136
142
  promptHistoryKey,
137
143
  } from './app/app-format.mjs';
@@ -419,39 +425,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
419
425
  beginRenameProject,
420
426
  openProjectPicker,
421
427
  } = projectPicker;
422
- // Theme/effort picker cluster — same dep set as the project picker; the
423
- // destructured openers are used inside handlers defined later in the body.
424
- const { openThemePicker, openEffortPicker } = createThemeEffortPickers({
425
- state,
426
- store,
427
- setPicker,
428
- setProviderPrompt,
429
- setChannelPrompt,
430
- setHookPrompt,
431
- setSettingsPrompt,
432
- setContextPanel,
433
- closeUsagePanel,
434
- clean,
435
- });
436
- // Resume picker — independent (store + setPicker + two session formatters).
437
- const { openResumePicker } = createResumePicker({
438
- store,
439
- setPicker,
440
- formatSessionUpdatedAt,
441
- formatSessionMessageCount,
442
- });
443
- // Core-memory picker cluster. The Esc-return target is passed per entry
444
- // point ({ returnTo }): Settings threads openSettingsPicker, standalone
445
- // /memory passes null so Esc closes instead of surfacing Settings.
446
- // Only openMemoryCorePicker is called from the App body; the entry-action /
447
- // add / edit / delete openers are internal to the factory (reached through
448
- // the picker's own onSelect closures), so they stay unbound here.
449
- const { openMemoryCorePicker } = createCoreMemoryPicker({
450
- store,
451
- setPicker,
452
- setSettingsPrompt,
453
- parseMemoryCoreRows,
454
- });
455
428
  const [disabledSkills, setDisabledSkillsInner] = useState(() => {
456
429
  try {
457
430
  const { disabled = [] } = store.getDisabledSkills?.() || {};
@@ -472,61 +445,28 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
472
445
  return set;
473
446
  });
474
447
  }, [store]);
475
- const {
476
- openMcpServersPicker,
477
- openMcpPicker,
478
- openProjectSkillsPicker,
479
- openSkillsPicker,
480
- openSkillDetailPicker,
481
- beginAddPlugin,
482
- openPluginDetailPicker,
483
- openInstalledPluginsPicker,
484
- openPluginsPicker,
485
- openHooksPicker,
486
- } = createExtensionPickers({
487
- store,
488
- theme,
489
- clean,
490
- copyToClipboard,
491
- setPicker,
492
- getPicker: () => livePickerRef.current,
493
- setProviderPrompt,
494
- setChannelPrompt,
495
- setHookPrompt,
496
- setSettingsPrompt,
497
- getDisabledSkills: () => disabledSkills,
498
- setDisabledSkills,
499
- });
500
- const {
501
- openUpdatePicker,
502
- openAutoClearPicker,
503
- openProfilePicker,
504
- } = createMaintenancePickers({
505
- store,
506
- theme,
507
- formatDuration,
508
- setPicker,
509
- setProviderPrompt,
510
- setChannelPrompt,
511
- setHookPrompt,
512
- setSettingsPrompt,
513
- setContextPanel,
514
- closeUsagePanel,
515
- });
516
448
  const toolApproval = state.toolApproval || null;
517
449
  const [promptDraft, setPromptDraft] = useState('');
518
450
  const [promptDraftOverride, setPromptDraftOverride] = useState(null);
519
451
  const promptLayoutValueRef = useRef('');
520
452
  const [, setPromptLayoutRows] = useState(1);
521
453
  const [textEntryLayoutRows, setTextEntryLayoutRows] = useState(1);
522
- const [, setPastedImages] = useState({});
523
- const pastedImagesRef = useRef({});
524
- const nextPastedImageIdRef = useRef(1);
525
- // Large pasted texts folded into [Pasted text #N +M lines] tokens; mirrors
526
- // pastedImagesRef. Original text is expanded back on submit.
527
- const [, setPastedTexts] = useState({});
528
- const pastedTextsRef = useRef({});
529
- const nextPastedTextIdRef = useRef(1);
454
+ // Pasted image/text buffers + their [ref-token] lifecycle:
455
+ // app/use-pasted-buffers.mjs.
456
+ const {
457
+ pastedImagesRef,
458
+ nextPastedImageIdRef,
459
+ pastedTextsRef,
460
+ nextPastedTextIdRef,
461
+ setPastedImages,
462
+ setPastedTexts,
463
+ installPastedImages,
464
+ clearPastedImagesSnapshot,
465
+ registerPastedImage,
466
+ installPastedTexts,
467
+ clearPastedTextsSnapshot,
468
+ registerPastedText,
469
+ } = usePastedBuffers();
530
470
  const promptValueRef = useRef('');
531
471
  const promptSelectionRef = useRef(null);
532
472
  // [mixdog] Prompt-box mouse selection wiring. boxRect is the editable text
@@ -538,30 +478,31 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
538
478
  const promptMouseSelectionRef = useRef(null);
539
479
  const promptHistoryNavRef = useRef({ active: false, index: -1, seed: '', lastValue: '' });
540
480
  const promptHistoryDraftChangeRef = useRef(false);
541
- const [promptHint, setPromptHint] = useState('');
542
- const [promptHintTone, setPromptHintTone] = useState('info');
543
- const [welcomePromptHintDismissed, setWelcomePromptHintDismissed] = useState(false);
544
- const [conditionalWelcomePromptHint, setConditionalWelcomePromptHint] = useState('');
545
- const welcomePromptHintRef = useRef(null);
546
- if (welcomePromptHintRef.current === null) {
547
- welcomePromptHintRef.current = randomWelcomePromptHint();
548
- }
549
- // Tracks whether the welcome hint row is ACTUALLY on screen this frame.
550
- // Dismissal must only fire while the user can see the hint, and ONLY when
551
- // the prompt draft gains its first character (see onPromptDraftChange).
552
- // Generic key/mouse events (arrows, wheel, terminal escape replies) used to
553
- // dismiss it instantly, so the hint vanished before the user ever typed.
554
- const welcomePromptHintVisibleRef = useRef(false);
555
- const dismissWelcomePromptHint = useCallback(() => {
556
- if (!welcomePromptHintVisibleRef.current) return;
557
- setWelcomePromptHintDismissed((dismissed) => dismissed || true);
558
- }, []);
481
+ // Transient hint band under the prompt: app/use-prompt-hint.mjs.
482
+ const {
483
+ promptHint,
484
+ promptHintTone,
485
+ promptHintTimerRef,
486
+ promptHintActiveRef,
487
+ showPromptHint,
488
+ showSelectionCopyHint,
489
+ clearPromptHint,
490
+ } = usePromptHint();
559
491
  const toastErrorSignature = useMemo(() => (
560
492
  (state.toasts || [])
561
493
  .filter((toast) => toast?.tone === 'error')
562
494
  .map((toast) => `${toast.id || ''}:${toast.text || ''}`)
563
495
  .join('|')
564
496
  ), [state.toasts]);
497
+ // Welcome-screen starter tip + conditional setup hints:
498
+ // app/use-welcome-prompt-hint.mjs.
499
+ const {
500
+ welcomePromptHintDismissed,
501
+ conditionalWelcomePromptHint,
502
+ welcomePromptHintRef,
503
+ welcomePromptHintVisibleRef,
504
+ dismissWelcomePromptHint,
505
+ } = useWelcomePromptHint({ store, state, toastErrorSignature });
565
506
  const [slashIndex, setSlashIndex] = useState(0);
566
507
  const [slashDismissedFor, setSlashDismissedFor] = useState('');
567
508
  const slashPaletteRef = useRef({ open: false, count: 0 });
@@ -621,166 +562,66 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
621
562
  timer.unref?.();
622
563
  return () => { alive = false; clearTimeout(timer); };
623
564
  }, [store]);
624
- // Onboarding wizard + channel setup picker factories. Instantiated here —
625
- // after the onboarding refs above (const-TDZ) — with later-defined openers
626
- // (openProviderSetupPicker/openOutputStylePicker) threaded as lazy getters
627
- // that resolve the live binding at call time.
628
- const { onboardingWarnReopen, openOnboardingAuthStep } = createOnboardingSteps({
629
- store,
630
- setPicker,
631
- setProviderPrompt,
632
- setChannelPrompt,
633
- setHookPrompt,
634
- setSettingsPrompt,
635
- setOnboardingActive,
636
- onboardingRef,
637
- providerModelsCacheRef,
638
- onboardingPrefetchSeqRef,
639
- openProviderSetupPicker: (...a) => openProviderSetupPicker(...a),
640
- openThemePicker,
641
- openOutputStylePicker: (...a) => openOutputStylePicker(...a),
642
- });
565
+ // Picker/panel factories + slash dispatch: app/create-app-pickers.mjs.
643
566
  const {
567
+ openThemePicker,
568
+ openEffortPicker,
569
+ openResumePicker,
570
+ openMemoryCorePicker,
571
+ openMcpServersPicker,
572
+ openMcpPicker,
573
+ openProjectSkillsPicker,
574
+ openSkillsPicker,
575
+ openSkillDetailPicker,
576
+ beginAddPlugin,
577
+ openPluginDetailPicker,
578
+ openInstalledPluginsPicker,
579
+ openPluginsPicker,
580
+ openHooksPicker,
581
+ openUpdatePicker,
582
+ openAutoClearPicker,
583
+ openProfilePicker,
584
+ onboardingWarnReopen,
585
+ openOnboardingAuthStep,
644
586
  openChannelTypeActionsPicker,
645
587
  openChannelSettingTypePicker,
646
588
  openChannelSetupPicker,
647
- } = createChannelPickers({
648
- store,
649
- setPicker,
650
- setProviderPrompt,
651
- setChannelPrompt,
652
- setHookPrompt,
653
- setSettingsPrompt,
654
- setContextPanel,
655
- onboardingWarnReopen,
656
- });
657
- const { openProviderSetupPicker } = createProviderSetupPicker({
658
- store,
659
- setPicker,
660
- setProviderPrompt,
661
- setChannelPrompt,
662
- setHookPrompt,
663
- setSettingsPrompt,
664
- setContextPanel,
665
- closeUsagePanel,
666
- oauthSubmitRef,
667
- clearModelCaches,
668
- });
669
- const { openModelPicker } = createModelPicker({
670
- store,
671
- getState: () => state,
672
- setPicker,
673
- setProviderPrompt,
674
- setChannelPrompt,
675
- setHookPrompt,
676
- setSettingsPrompt,
677
- providerModelsCacheRef,
678
- searchModelsCacheRef,
679
- modelPickerRequestRef,
680
- clearModelCaches,
681
- modelSwitchNotice,
682
589
  openProviderSetupPicker,
683
- });
684
- const {
590
+ openModelPicker,
685
591
  openSearchPicker,
686
592
  openAgentsPicker,
687
593
  openWorkflowPicker,
688
594
  openOutputStylePicker,
689
- openBridgePicker,
690
- openToolsPicker,
691
- } = createRoutePickers({
692
- store,
595
+ openSettingsPicker,
596
+ runSlashCommand,
597
+ } = createAppPickers({
693
598
  state,
694
- setPicker,
695
- setProviderPrompt,
696
- setChannelPrompt,
697
- setHookPrompt,
698
- setSettingsPrompt,
699
- setContextPanel,
700
- closeUsagePanel,
701
- clean,
702
- copyToClipboard,
703
- routeLabel,
704
- agentModelParts,
705
- agentModelProfile,
706
- workflowSwitchNotice,
707
- openModelPicker: (...a) => openModelPicker(...a),
708
- });
709
- const { openSettingsPicker } = createSettingsPicker({
710
599
  store,
711
- state,
712
600
  setPicker,
713
601
  setProviderPrompt,
714
602
  setChannelPrompt,
715
603
  setHookPrompt,
716
604
  setSettingsPrompt,
717
- settingsHeavyCacheRef,
718
- formatDuration,
719
- displayModelName,
720
- routeModelLabel,
721
- workflowDisplayName,
722
- workflowSwitchNotice,
723
- themeNotice,
724
- openModelPicker: (...a) => openModelPicker(...a),
725
- openSearchPicker: (...a) => openSearchPicker(...a),
726
- openAgentsPicker: (...a) => openAgentsPicker(...a),
727
- openWorkflowPicker: (...a) => openWorkflowPicker(...a),
728
- openOutputStylePicker: (...a) => openOutputStylePicker(...a),
729
- openBridgePicker: (...a) => openBridgePicker(...a),
730
- openToolsPicker: (...a) => openToolsPicker(...a),
731
- openProviderSetupPicker: (...a) => openProviderSetupPicker(...a),
732
- openThemePicker: (...a) => openThemePicker(...a),
733
- openAutoClearPicker: (...a) => openAutoClearPicker(...a),
734
- openProfilePicker: (...a) => openProfilePicker(...a),
735
- openMcpPicker: (...a) => openMcpPicker(...a),
736
- openPluginsPicker: (...a) => openPluginsPicker(...a),
737
- openHooksPicker: (...a) => openHooksPicker(...a),
738
- openSkillsPicker: (...a) => openSkillsPicker(...a),
739
- openUpdatePicker: (...a) => openUpdatePicker(...a),
740
- openChannelSettingTypePicker: (...a) => openChannelSettingTypePicker(...a),
741
- });
742
- const { runSlashCommand } = createSlashDispatch({
743
- state,
744
- store,
745
- normalizeSlashCommandName,
746
605
  setContextPanel,
606
+ setOnboardingActive,
747
607
  closeUsagePanel,
748
- openModelPicker: (...a) => openModelPicker(...a),
749
- modelSwitchNotice,
750
- openSearchPicker: (...a) => openSearchPicker(...a),
751
- openAgentsPicker: (...a) => openAgentsPicker(...a),
752
- openWorkflowPicker: (...a) => openWorkflowPicker(...a),
753
- workflowSwitchNotice,
754
- openOutputStylePicker: (...a) => openOutputStylePicker(...a),
755
- outputStyleNotice,
756
- openThemePicker: (...a) => openThemePicker(...a),
757
- themeNotice,
758
- openEffortPicker: (...a) => openEffortPicker(...a),
759
- projectNameFromPath,
760
- enterProject: (...a) => enterProject(...a),
761
- openProjectPicker: (...a) => openProjectPicker(...a),
762
- openToolsPicker: (...a) => openToolsPicker(...a),
763
- openMcpPicker: (...a) => openMcpPicker(...a),
764
- openSkillsPicker: (...a) => openSkillsPicker(...a),
765
- openPluginsPicker: (...a) => openPluginsPicker(...a),
766
- openHooksPicker: (...a) => openHooksPicker(...a),
767
- openProviderSetupPicker: (...a) => openProviderSetupPicker(...a),
768
- openChannelSetupPicker: (...a) => openChannelSetupPicker(...a),
769
- openMemoryCorePicker: (...a) => openMemoryCorePicker(...a),
770
- parseMemoryCommand,
771
- openSettingsPicker: (...a) => openSettingsPicker(...a),
772
- openAutoClearPicker: (...a) => openAutoClearPicker(...a),
773
- formatDuration,
774
- openResumePicker: (...a) => openResumePicker(...a),
608
+ oauthSubmitRef,
609
+ clearModelCaches,
610
+ onboardingRef,
611
+ providerModelsCacheRef,
612
+ searchModelsCacheRef,
613
+ modelPickerRequestRef,
614
+ onboardingPrefetchSeqRef,
615
+ settingsHeavyCacheRef,
616
+ livePickerRef,
617
+ disabledSkills,
618
+ setDisabledSkills,
619
+ enterProject,
620
+ openProjectPicker,
621
+ requestExit: (...a) => requestExit(...a),
775
622
  openUsagePanel: (...a) => openUsagePanel(...a),
776
623
  openContextPicker: (...a) => openContextPicker(...a),
777
- openProfilePicker: (...a) => openProfilePicker(...a),
778
- openUpdatePicker: (...a) => openUpdatePicker(...a),
779
- runDoctor: (...a) => store.runDoctor?.(...a),
780
- requestExit: (...a) => requestExit(...a),
781
624
  });
782
- const promptHintTimerRef = useRef(null);
783
- const promptHintActiveRef = useRef(false);
784
625
  // dragRef tracks an in-progress mouse text selection (see the mouse handler):
785
626
  // anchor = where the drag began, last = the latest cell, active = button held.
786
627
  // region: which surface the in-progress (or last) selection belongs to —
@@ -820,65 +661,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
820
661
  const cols = Math.max(1, frameColumns - 4 - stringWidth('Sentence > '));
821
662
  setTextEntryLayoutRows(textEntryReservedRows(settingsPrompt?.initialValue, cols, 8));
822
663
  }, [settingsPrompt?.kind, settingsPrompt?.initialValue, frameColumns]);
823
- useEffect(() => {
824
- let alive = true;
825
- const refreshConditionalWelcomeHint = async () => {
826
- let next = '';
827
- try {
828
- const setup = await store.getProviderSetup?.();
829
- if (setup && !providerSetupHasUsableProvider(setup)) {
830
- next = CONDITIONAL_WELCOME_PROMPT_HINTS.noProvider;
831
- }
832
- } catch {
833
- // If provider setup probing fails, let the generic/error tip path decide.
834
- }
835
- if (!next) {
836
- const activeProvider = String(state.provider || '').trim();
837
- const activeModel = String(state.model || '').trim();
838
- if (!activeProvider || !activeModel) {
839
- next = CONDITIONAL_WELCOME_PROMPT_HINTS.noModel;
840
- } else {
841
- try {
842
- const models = await Promise.resolve(store.listProviderModels?.({ quick: true }) || []);
843
- if (Array.isArray(models) && models.length === 0) {
844
- next = CONDITIONAL_WELCOME_PROMPT_HINTS.noModel;
845
- }
846
- } catch {
847
- // Model probing is advisory only; avoid replacing the random hint on failure.
848
- }
849
- }
850
- }
851
- const activeWorkflow = activeWorkflowSummaryForStore(store, state.workflow || {});
852
- if (!next && String(activeWorkflow?.id || state.workflow?.id || '').toLowerCase() === 'solo') {
853
- next = CONDITIONAL_WELCOME_PROMPT_HINTS.soloWorkflow;
854
- }
855
- if (!next) {
856
- const searchRoute = store.getSearchRoute?.() || null;
857
- const searchProvider = String(searchRoute?.provider || '').trim();
858
- const searchModel = String(searchRoute?.model || '').trim();
859
- const defaultSearchRoute = searchProvider.toLowerCase() === 'default' && searchModel.toLowerCase() === 'default';
860
- if (defaultSearchRoute) {
861
- try {
862
- const models = await Promise.resolve(store.listProviderModels?.({ quick: true }) || []);
863
- const current = Array.isArray(models)
864
- ? models.find((model) => model?.provider === state.provider && model?.id === state.model)
865
- : null;
866
- if (current && current.supportsWebSearch !== true) {
867
- next = CONDITIONAL_WELCOME_PROMPT_HINTS.searchDefaultUnsupported;
868
- }
869
- } catch {
870
- // Search default probing is advisory only.
871
- }
872
- }
873
- }
874
- if (!next && toastErrorSignature) {
875
- next = CONDITIONAL_WELCOME_PROMPT_HINTS.error;
876
- }
877
- if (alive) setConditionalWelcomePromptHint((prev) => (prev === next ? prev : next));
878
- };
879
- void refreshConditionalWelcomeHint();
880
- return () => { alive = false; };
881
- }, [store, state.provider, state.model, state.workflow?.id, toastErrorSignature]);
882
664
  const selectionLayoutRef = useRef(null);
883
665
  const selectionTextRef = useRef('');
884
666
  // lastClickRef tracks the previous left-press cell + time so the mouse handler
@@ -889,19 +671,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
889
671
  // press resets to a fresh single.
890
672
  const lastClickRef = useRef({ x: -1, y: -1, t: 0, count: 0 });
891
673
 
892
- const showSelectionCopyHint = useCallback((text, tone = 'plain') => {
893
- if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
894
- promptHintActiveRef.current = true;
895
- setPromptHint(String(text || ''));
896
- setPromptHintTone(tone);
897
- promptHintTimerRef.current = setTimeout(() => {
898
- promptHintTimerRef.current = null;
899
- promptHintActiveRef.current = false;
900
- setPromptHint('');
901
- setPromptHintTone('info');
902
- }, 2200);
903
- }, []);
904
-
905
674
  // ── Post-mount input gate ──────────────────────────────────────────────
906
675
  // Let one event-loop poll pass so Ink processes (and discards, because
907
676
  // PromptInput is still disabled) any keystrokes queued during boot/first
@@ -911,155 +680,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
911
680
  return () => clearTimeout(timer);
912
681
  }, []);
913
682
 
914
- useEffect(() => {
915
- if (!stdout) return undefined;
916
- let trailing = null;
917
- // Leading-edge fire on the first event of a burst, then coalesce the rest:
918
- // events arriving within DEBOUNCE_MS skip the immediate full-layout update
919
- // and only refresh the (reset) trailing timer, so a resize storm commits at
920
- // most once per window plus one settle update instead of once per event.
921
- const DEBOUNCE_MS = 80;
922
- let lastRun = 0;
923
- const update = () => {
924
- setResizeState((prev) => {
925
- const next = terminalSize(stdout);
926
- // No-op when dimensions are unchanged: the unconditional post-mount
927
- // update() otherwise forces an extra full-frame commit (epoch bump)
928
- // right after the first paint, which reads as a boot flicker.
929
- if (next.columns === prev.columns && next.rows === prev.rows) return prev;
930
- return {
931
- ...next,
932
- epoch: prev.epoch + 1,
933
- };
934
- });
935
- };
936
- const onResize = () => {
937
- const now = Date.now();
938
- if (now - lastRun >= DEBOUNCE_MS) {
939
- lastRun = now;
940
- update();
941
- }
942
- if (trailing) clearTimeout(trailing);
943
- trailing = setTimeout(() => {
944
- trailing = null;
945
- lastRun = Date.now();
946
- update();
947
- }, DEBOUNCE_MS);
948
- };
949
- stdout.on('resize', onResize);
950
- update();
951
- return () => {
952
- if (trailing) clearTimeout(trailing);
953
- stdout.off('resize', onResize);
954
- };
955
- }, [stdout]);
956
-
957
- const clearPromptHint = useCallback(() => {
958
- if (!promptHintActiveRef.current && !promptHintTimerRef.current) return;
959
- if (promptHintTimerRef.current) {
960
- clearTimeout(promptHintTimerRef.current);
961
- promptHintTimerRef.current = null;
962
- }
963
- promptHintActiveRef.current = false;
964
- setPromptHint('');
965
- setPromptHintTone('info');
966
- }, []);
967
-
968
- const showPromptHint = useCallback((text, tone = 'info') => {
969
- if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
970
- promptHintActiveRef.current = true;
971
- setPromptHint(String(text || ''));
972
- setPromptHintTone(tone);
973
- promptHintTimerRef.current = setTimeout(() => {
974
- promptHintTimerRef.current = null;
975
- promptHintActiveRef.current = false;
976
- setPromptHint('');
977
- setPromptHintTone('info');
978
- }, 2200);
979
- }, []);
980
-
981
- const installPastedImages = useCallback((images, { merge = true } = {}) => {
982
- if (!images || typeof images !== 'object' || Object.keys(images).length === 0) return;
983
- const next = merge ? { ...pastedImagesRef.current, ...images } : { ...images };
984
- pastedImagesRef.current = next;
985
- const maxId = Object.keys(next)
986
- .map((id) => Number(id) || 0)
987
- .reduce((max, id) => Math.max(max, id), 0);
988
- if (maxId >= nextPastedImageIdRef.current) nextPastedImageIdRef.current = maxId + 1;
989
- setPastedImages(next);
990
- }, []);
991
-
992
- const clearPastedImagesSnapshot = useCallback((snapshot = null) => {
993
- if (!snapshot) {
994
- if (Object.keys(pastedImagesRef.current || {}).length === 0) return;
995
- pastedImagesRef.current = {};
996
- setPastedImages({});
997
- return;
998
- }
999
- if (typeof snapshot !== 'object' || Object.keys(snapshot).length === 0) return;
1000
- const next = { ...pastedImagesRef.current };
1001
- let changed = false;
1002
- for (const [id, image] of Object.entries(snapshot)) {
1003
- if (next[id] === image) {
1004
- delete next[id];
1005
- changed = true;
1006
- }
1007
- }
1008
- if (!changed) return;
1009
- pastedImagesRef.current = next;
1010
- setPastedImages(next);
1011
- }, []);
1012
-
1013
- const registerPastedImage = useCallback((image) => {
1014
- if (!image || image.type !== 'image' || !image.content) return '';
1015
- const id = nextPastedImageIdRef.current++;
1016
- const entry = { ...image, id };
1017
- pastedImagesRef.current = { ...pastedImagesRef.current, [id]: entry };
1018
- setPastedImages(pastedImagesRef.current);
1019
- return formatImageRef(id);
1020
- }, []);
1021
-
1022
- const installPastedTexts = useCallback((texts, { merge = true } = {}) => {
1023
- if (!texts || typeof texts !== 'object' || Object.keys(texts).length === 0) return;
1024
- const next = merge ? { ...pastedTextsRef.current, ...texts } : { ...texts };
1025
- pastedTextsRef.current = next;
1026
- const maxId = Object.keys(next)
1027
- .map((id) => Number(id) || 0)
1028
- .reduce((max, id) => Math.max(max, id), 0);
1029
- if (maxId >= nextPastedTextIdRef.current) nextPastedTextIdRef.current = maxId + 1;
1030
- setPastedTexts(next);
1031
- }, []);
1032
-
1033
- const clearPastedTextsSnapshot = useCallback((snapshot = null) => {
1034
- if (!snapshot) {
1035
- if (Object.keys(pastedTextsRef.current || {}).length === 0) return;
1036
- pastedTextsRef.current = {};
1037
- setPastedTexts({});
1038
- return;
1039
- }
1040
- if (typeof snapshot !== 'object' || Object.keys(snapshot).length === 0) return;
1041
- const next = { ...pastedTextsRef.current };
1042
- let changed = false;
1043
- for (const [id, text] of Object.entries(snapshot)) {
1044
- if (next[id] === text) {
1045
- delete next[id];
1046
- changed = true;
1047
- }
1048
- }
1049
- if (!changed) return;
1050
- pastedTextsRef.current = next;
1051
- setPastedTexts(next);
1052
- }, []);
1053
-
1054
- const registerPastedText = useCallback((text) => {
1055
- const value = String(text ?? '');
1056
- if (!value) return '';
1057
- const id = nextPastedTextIdRef.current++;
1058
- const entry = { id, text: value };
1059
- pastedTextsRef.current = { ...pastedTextsRef.current, [id]: entry };
1060
- setPastedTexts(pastedTextsRef.current);
1061
- return formatPastedTextRef(id, value);
1062
- }, []);
683
+ // Resize debounce + extended-keyboard enable: app/use-terminal-chrome.mjs.
684
+ useTerminalChrome({ stdout, isRawModeSupported, setResizeState });
1063
685
 
1064
686
  // Transcript scroll + grid-selection engine: extracted to app/use-transcript-scroll.mjs.
1065
687
  const {
@@ -1098,60 +720,20 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1098
720
  selectionTextRef,
1099
721
  });
1100
722
 
1101
- // Copy the currently-highlighted selection to the OS clipboard. ink's fork
1102
- // refreshed store.getRenderSelectionText() on the synchronous render that the
1103
- // final setSelection() triggered, so the selected text is ready to read.
1104
- // NOTE: declared after useTranscriptScroll — the dependency array below
1105
- // evaluates getStitchedSelectionText at render time, so referencing it
1106
- // before the destructuring above is a TDZ ReferenceError.
1107
- const copySelection = useCallback((attempt = 0) => {
1108
- const renderText = store.getRenderSelectionText?.();
1109
- const remembered = selectionTextRef.current || '';
1110
- // A selection that has partially scrolled out of the viewport renders —
1111
- // and therefore harvests — only its visible rows. The remembered text
1112
- // (captured while the selection was last fully painted) is the fuller
1113
- // copy; prefer whichever is longer so scrolling never shrinks a copy.
1114
- let text = renderText == null
1115
- ? remembered
1116
- : (remembered.length > renderText.length ? remembered : renderText);
1117
- // The stitch buffer accumulates rows harvested across every scroll position
1118
- // during a transcript drag, so it can reconstruct rows that scrolled out of
1119
- // view entirely (neither renderText nor the last-full-paint remembered text
1120
- // ever saw them). getStitchedSelectionText now reports a `complete` flag:
1121
- // prefer the stitch ONLY when it contiguously covers the selection (no
1122
- // interior gap) AND adds rows. A gapped stitch silently drops a scrolled-off
1123
- // row, so preferring it purely on length yielded a mangled copy.
1124
- const stitched = getStitchedSelectionText?.() || { text: '', complete: false };
1125
- if (stitched.complete && stitched.text.length > text.length) text = stitched.text;
1126
- if ((!text || !text.trim()) && attempt < 4) {
1127
- setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
1128
- return;
1129
- }
1130
- if (!text || !text.trim()) {
1131
- // Retries exhausted with nothing to copy: never return silently — the
1132
- // user pressed Ctrl+C expecting feedback. Surface a hint (and still
1133
- // swallow the key, which the caller already did).
1134
- showSelectionCopyHint('nothing to copy · select text first', 'error');
1135
- return;
1136
- }
1137
- selectionTextRef.current = text;
1138
- copyToClipboard(text)
1139
- .then(() => {
1140
- const lines = text.split('\n').length;
1141
- const chars = text.length;
1142
- showSelectionCopyHint(`copied ${chars} char${chars === 1 ? '' : 's'}${lines > 1 ? ` · ${lines} lines` : ''}`, 'plain');
1143
- })
1144
- .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
1145
- }, [store, showSelectionCopyHint, getStitchedSelectionText]);
723
+ // Ctrl+C selection copy (render/remembered/stitch merge + retry + hint):
724
+ // app/use-copy-selection.mjs. Declared after useTranscriptScroll so
725
+ // getStitchedSelectionText exists (TDZ).
726
+ const { copySelection } = useCopySelection({
727
+ store,
728
+ selectionTextRef,
729
+ getStitchedSelectionText,
730
+ showSelectionCopyHint,
731
+ });
1146
732
 
1147
733
  useEffect(() => () => {
1148
734
  stopSmoothScroll();
1149
735
  }, [stopSmoothScroll]);
1150
736
 
1151
- useEffect(() => () => {
1152
- if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
1153
- }, []);
1154
-
1155
737
  // SGR mouse handling: extracted to app/use-mouse-input.mjs (useMouseInput).
1156
738
  useMouseInput({
1157
739
  inkInput,
@@ -1181,26 +763,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1181
763
  clearStitchBuffer,
1182
764
  });
1183
765
 
1184
- // Enable extended keyboard reporting (kitty + xterm modifyOtherKeys)
1185
- // SYNCHRONOUSLY, ONCE, with NO query/round-trip. ink
1186
- // turns raw mode on during the first useInput mount (synchronously, inside
1187
- // render); this mount effect runs in the same commit phase, right after — i.e.
1188
- // before the user can realistically press a key. We write BOTH enables
1189
- // unconditionally (the terminal honors whichever it implements; Windows
1190
- // Terminal 1.24 has no kitty but DOES honor modifyOtherKeys), gated only by the
1191
- // supportsExtendedKeys() allowlist. Because the enable lands before the first
1192
- // keypress is read, the FIRST Ctrl+Enter already arrives as a distinguishable
1193
- // \x1b[27;5;13~ (or kitty \x1b[13;5u) instead of a bare \r — fixing the old
1194
- // "first Ctrl+Enter submits, second works" race. Teardown lives in index.jsx's
1195
- // restoreTerminal(). The empty dep array makes this run exactly once on mount.
1196
- // eslint-disable-next-line react-hooks/exhaustive-deps
1197
- useEffect(() => {
1198
- if (!isRawModeSupported || !stdout?.write) return;
1199
- if (!supportsExtendedKeys()) return;
1200
- try {
1201
- stdout.write(ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS);
1202
- } catch { /* terminal may be closing */ }
1203
- }, []);
1204
766
 
1205
767
  // Item-count changes are the only time we can arm follow before row totals are
1206
768
  // recomputed. Pure streaming height growth is handled in the row-delta effect.
@@ -1216,79 +778,28 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1216
778
  if (scrollTargetRef.current === 0 || followingRef.current) followingRef.current = true;
1217
779
  }, [state.items.length, resetTranscriptScroll]);
1218
780
 
1219
- // `exiting` removes the inline caret (PromptInput draws none when disabled) and
1220
- // freezes input for the teardown frame, so the final frame is clean before ink
1221
- // unmounts. Exit just past the render throttle window so that frame flushes.
1222
- const requestExit = useCallback(() => {
1223
- if (exitRequestedRef.current) return;
1224
- exitRequestedRef.current = true;
1225
- setExiting(true);
1226
- const hardExitTimer = setTimeout(() => {
1227
- try { process.stdout.write('\x1b[?25h\x1b[0m'); } catch {}
1228
- process.exit(0);
1229
- }, 2000);
1230
- hardExitTimer.unref?.();
1231
- setTimeout(() => {
1232
- let timer = null;
1233
- Promise.race([
1234
- Promise.resolve(store.dispose?.('cli-react-exit', { detach: true })),
1235
- new Promise((resolve) => {
1236
- timer = setTimeout(resolve, 350);
1237
- }),
1238
- ]).finally(() => {
1239
- if (timer) clearTimeout(timer);
1240
- exit();
1241
- });
1242
- }, 60);
1243
- }, [store, exit]);
1244
-
1245
- const restoreQueuedToPrompt = useCallback((options = {}) => {
1246
- const restoreDraft = options.restoreDraft !== false;
1247
- const showHint = options.showHint !== false;
1248
- const currentText = options.currentText ?? promptValueRef.current ?? promptDraft;
1249
- const restored = store.restoreQueued?.(currentText);
1250
- if (!restored || restored.count === 0) {
1251
- if (showHint) showPromptHint('No queued messages to restore.', 'info');
1252
- return false;
1253
- }
1254
- if (restoreDraft) {
1255
- if (restored.pastedImages) installPastedImages(restored.pastedImages, { merge: true });
1256
- if (restored.pastedTexts) installPastedTexts(restored.pastedTexts, { merge: true });
1257
- syncPromptLayoutRows(restored.text);
1258
- setPromptDraftOverride({ id: Date.now(), value: restored.text });
1259
- }
1260
- if (showHint) {
1261
- showPromptHint(`restored ${restored.count} queued message${restored.count === 1 ? '' : 's'}`, 'info');
1262
- } else {
1263
- clearPromptHint();
1264
- }
1265
- return true;
1266
- }, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages, syncPromptLayoutRows]);
1267
-
1268
- const recentPromptHistory = useMemo(() => {
1269
- // The engine maintains this list incrementally (rebuilt only when a user
1270
- // item is appended or the transcript is bulk-swapped), so App no longer
1271
- // rescans all items on every transcript change. Fall back to a local scan
1272
- // only if the engine did not publish it (older snapshot).
1273
- if (Array.isArray(state.promptHistoryList)) return state.promptHistoryList;
1274
- const items = Array.isArray(state.items) ? state.items : [];
1275
- const seen = new Set();
1276
- const history = [];
1277
- for (let i = items.length - 1; i >= 0 && history.length < PROMPT_HISTORY_LIMIT; i -= 1) {
1278
- const item = items[i];
1279
- if (item?.kind !== 'user') continue;
1280
- const text = String(item.text || '').trim();
1281
- const key = promptHistoryKey(text);
1282
- if (!key || seen.has(key)) continue;
1283
- seen.add(key);
1284
- history.push(text);
1285
- }
1286
- return history;
1287
- }, [state.promptHistoryList, state.items]);
1288
-
1289
- const resetPromptHistoryNav = useCallback(() => {
1290
- promptHistoryNavRef.current = { active: false, index: -1, seed: '', lastValue: '' };
1291
- }, []);
781
+ // Exit + queued-restore + prompt history: app/use-prompt-queue-history.mjs.
782
+ const {
783
+ requestExit,
784
+ restoreQueuedToPrompt,
785
+ recentPromptHistory,
786
+ resetPromptHistoryNav,
787
+ } = usePromptQueueHistory({
788
+ store,
789
+ state,
790
+ exit,
791
+ exitRequestedRef,
792
+ setExiting,
793
+ promptValueRef,
794
+ promptDraft,
795
+ showPromptHint,
796
+ clearPromptHint,
797
+ installPastedImages,
798
+ installPastedTexts,
799
+ syncPromptLayoutRows,
800
+ setPromptDraftOverride,
801
+ promptHistoryNavRef,
802
+ });
1292
803
 
1293
804
  // PROMPT HANDLER cluster extracted to app/use-prompt-handlers.mjs.
1294
805
  const {
@@ -1333,395 +844,48 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1333
844
  setToolOutputExpanded((expanded) => !expanded);
1334
845
  }, []);
1335
846
 
1336
- useInput((input, key) => {
1337
- if (toolApproval) {
1338
- const value = String(input || '').trim().toLowerCase();
1339
- if (key.escape || value === 'd' || value === 'n') {
1340
- store.resolveToolApproval?.(toolApproval.id, { approved: false, reason: 'denied by user' });
1341
- return;
1342
- }
1343
- if (value === 'a' || value === 'y') {
1344
- store.resolveToolApproval?.(toolApproval.id, { approved: true, reason: 'approved by user' });
1345
- return;
1346
- }
1347
- }
1348
- if (key.ctrl && (input === 'c' || input === 'C')) {
1349
- // Ctrl+C is copy-first. Native terminal selections can still forward the
1350
- // key event to us on Windows Terminal, so a missing app-owned selection
1351
- // must NOT cancel the active turn; use Esc to interrupt instead.
1352
- // Region-aware copy source: a prompt-box selection (its OWN engine) copies
1353
- // from promptSelectionRef; a transcript/status ink-grid selection copies
1354
- // from store.getRenderSelectionText via copySelection(). Only one region is
1355
- // ever active at a time (a press in one region clears the others), but when
1356
- // the last drag was in the prompt we prefer its selection explicitly.
1357
- const promptSelectionText = promptSelectionRef.current?.text;
1358
- const lastRegion = dragRef.current.region;
1359
- const inkRect = dragRef.current.rect;
1360
- const hasInkSelection = inkRect && !(inkRect.x1 === inkRect.x2 && inkRect.y1 === inkRect.y2);
1361
- if (promptSelectionText && (lastRegion === 'prompt' || !hasInkSelection)) {
1362
- copyToClipboard(promptSelectionText)
1363
- .then(() => showSelectionCopyHint(`copied ${promptSelectionText.length} char${promptSelectionText.length === 1 ? '' : 's'}`, 'plain'))
1364
- .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
1365
- return;
1366
- }
1367
- if (hasInkSelection) {
1368
- copySelection();
1369
- return;
1370
- }
1371
- // No app-owned selection. On Windows Terminal the same Ctrl+C is also the
1372
- // native terminal's copy shortcut for a mouse selection we can't see — so
1373
- // rendering a hint here fights that copy and flashes a spurious message.
1374
- // Suppress the hint on win32 (interrupt routing is unchanged: Esc still
1375
- // interrupts). Other platforms keep the guidance.
1376
- if (process.platform !== 'win32') {
1377
- showSelectionCopyHint('select text to copy · Esc interrupts', 'plain');
1378
- }
1379
- return;
1380
- }
1381
- if (key.ctrl && (input === 'o' || input === 'O')) {
1382
- toggleExpand();
1383
- return;
1384
- }
1385
- const rawShiftUp = input === '\x1b[1;2A' || input === '\x1b[a' || input === '[1;2A';
1386
- const rawShiftDown = input === '\x1b[1;2B' || input === '\x1b[b' || input === '[1;2B';
1387
- const rawShiftRight = input === '\x1b[1;2C' || input === '\x1b[c' || input === '[1;2C';
1388
- const rawShiftLeft = input === '\x1b[1;2D' || input === '\x1b[d' || input === '[1;2D';
1389
- const rawCtrlShiftUp = input === '\x1b[1;6A' || input === '[1;6A';
1390
- const rawCtrlShiftDown = input === '\x1b[1;6B' || input === '[1;6B';
1391
- const rawCtrlShiftRight = input === '\x1b[1;6C' || input === '[1;6C';
1392
- const rawCtrlShiftLeft = input === '\x1b[1;6D' || input === '[1;6D';
1393
- const rawModifiedShiftArrow = rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight
1394
- || rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight;
1395
- if (
1396
- !picker
1397
- && (
1398
- (key.shift && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end))
1399
- || rawModifiedShiftArrow
1400
- )
1401
- ) {
1402
- // Consume the chord whenever a transcript/status ink-grid selection is
1403
- // live — even if the focus clamps at an edge (moveSelectionFocus returns
1404
- // false there). PromptInput independently skips the same chord via the
1405
- // shared gridSelectionActiveRef predicate, so there is no double-handling.
1406
- // When no grid selection is live, fall through to PromptInput.
1407
- let move = null;
1408
- if (key.leftArrow || rawShiftLeft || rawCtrlShiftLeft) move = 'left';
1409
- else if (key.rightArrow || rawShiftRight || rawCtrlShiftRight) move = 'right';
1410
- else if (key.upArrow || rawShiftUp || rawCtrlShiftUp) move = 'up';
1411
- else if (key.downArrow || rawShiftDown || rawCtrlShiftDown) move = 'down';
1412
- else if (key.home) move = 'lineStart';
1413
- else if (key.end) move = 'lineEnd';
1414
- if (move && gridSelectionActiveRef.current()) {
1415
- moveSelectionFocus(move);
1416
- return;
1417
- }
1418
- }
1419
- if (key.escape && usagePanel && !picker) {
1420
- closeUsagePanel();
1421
- return;
1422
- }
1423
- if (key.escape && contextPanel && !picker) {
1424
- setContextPanel(null);
1425
- return;
1426
- }
1427
- if (key.pageUp) {
1428
- if (overlayBlocksGlobalTranscriptScroll(scrollFocusRef.current)) return;
1429
- const pageRows = Math.max(3, Math.floor((resizeState.rows ?? 24) * 0.6));
1430
- scrollTranscriptRows(pageRows);
1431
- return;
1432
- }
1433
- if (key.pageDown) {
1434
- if (overlayBlocksGlobalTranscriptScroll(scrollFocusRef.current)) return;
1435
- const pageRows = Math.max(3, Math.floor((resizeState.rows ?? 24) * 0.6));
1436
- scrollTranscriptRows(-pageRows);
1437
- return;
1438
- }
1439
- if (key.ctrl && key.end) {
1440
- resetTranscriptScroll();
1441
- return;
1442
- }
1443
- if (key.escape && !picker) {
1444
- dragRef.current.active = false;
1445
- dragRef.current.region = null;
1446
- dragRef.current.anchorSpan = null;
1447
- // Clear whichever region's selection is active. PromptInput's own ESC also
1448
- // clears its selection when focused/enabled; this covers the disabled case
1449
- // and a status/transcript ink-grid selection in one press.
1450
- promptMouseSelectionRef.current?.clear?.();
1451
- applySelectionRect(null);
1452
- }
1453
- }, { isActive: isRawModeSupported });
1454
-
1455
- const openUsagePanel = (arg = '') => {
1456
- const refresh = /(?:^|\s)(?:refresh|--refresh|-r|true)(?:\s|$)/i.test(String(arg || ''));
1457
- const requestId = usageRequestRef.current + 1;
1458
- usageRequestRef.current = requestId;
1459
- setProviderPrompt(null);
1460
- setChannelPrompt(null);
1461
- setHookPrompt(null);
1462
- setSettingsPrompt(null);
1463
- setPicker(null);
1464
- setContextPanel(null);
1465
- setUsagePanel({
1466
- title: 'Provider Quotas',
1467
- subtitle: 'Statusline-style provider quota windows.',
1468
- checking: true,
1469
- refresh,
1470
- rows: [],
1471
- total: null,
1472
- });
1473
- setTimeout(() => {
1474
- if (usageRequestRef.current !== requestId) return;
1475
- void store.getUsageDashboard?.({
1476
- refresh,
1477
- onUpdate: (dashboard) => {
1478
- if (usageRequestRef.current !== requestId) return;
1479
- if (!dashboard) return;
1480
- setUsagePanel(dashboard);
1481
- },
1482
- })
1483
- .then((dashboard) => {
1484
- if (usageRequestRef.current !== requestId) return;
1485
- if (!dashboard) {
1486
- closeUsagePanel();
1487
- store.pushNotice('usage dashboard unavailable', 'warn');
1488
- return;
1489
- }
1490
- setUsagePanel(dashboard);
1491
- })
1492
- .catch((e) => {
1493
- if (usageRequestRef.current !== requestId) return;
1494
- closeUsagePanel();
1495
- store.pushNotice(`usage failed: ${e?.message || e}`, 'error');
1496
- });
1497
- }, 0);
1498
- };
847
+ // App-level key handling (approval keys, copy-first Ctrl+C, grid-selection
848
+ // chords, panel Escapes, transcript paging): app/use-global-key-input.mjs.
849
+ useGlobalKeyInput({
850
+ store,
851
+ state,
852
+ toolApproval,
853
+ picker,
854
+ usagePanel,
855
+ contextPanel,
856
+ setContextPanel,
857
+ closeUsagePanel,
858
+ isRawModeSupported,
859
+ resizeState,
860
+ promptSelectionRef,
861
+ promptMouseSelectionRef,
862
+ dragRef,
863
+ scrollFocusRef,
864
+ gridSelectionActiveRef,
865
+ moveSelectionFocus,
866
+ copySelection,
867
+ showSelectionCopyHint,
868
+ toggleExpand,
869
+ scrollTranscriptRows,
870
+ resetTranscriptScroll,
871
+ applySelectionRect,
872
+ });
1499
873
 
1500
- const openContextPicker = () => {
1501
- const tools = store.toolsStatus?.() || {
1502
- activeCount: 0,
1503
- count: 0,
1504
- mcpToolCount: 0,
1505
- activeMcpToolCount: 0,
1506
- activeTools: [],
1507
- };
1508
- const mcp = store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 };
1509
- const skills = store.skillsStatus?.() || { count: 0 };
1510
- const plugins = store.pluginsStatus?.() || { count: 0 };
1511
- const context = store.contextStatus?.() || {};
1512
- const usage = context.usage || {};
1513
- const messages = context.messages || {};
1514
- const request = context.request || {};
1515
- const schemaBreakdown = request.toolSchemaBreakdown || {};
1516
- const schemaTokensFor = (buckets) => buckets.reduce(
1517
- (sum, bucket) => sum + Number(schemaBreakdown?.[bucket]?.tokens || 0),
1518
- 0,
1519
- );
1520
- const builtInToolSchemaTokens = schemaTokensFor(['code', 'web', 'mutation', 'channels', 'setup', 'other']);
1521
- const mcpToolSchemaTokens = schemaTokensFor(['mcp']);
1522
- const compaction = context.compaction || {};
1523
- const windowTokens = Number(context.contextWindow || state.contextWindow || context.rawContextWindow || state.rawContextWindow || 0);
1524
- const rawWindowTokens = Number(context.rawContextWindow || state.rawContextWindow || windowTokens || 0);
1525
- // Compaction boundary/trigger are sourced from the runtime contextStatus
1526
- // (context.compaction). Fall back to the visible window for the boundary
1527
- // and to the boundary for the trigger so /context still renders on a
1528
- // fresh/resumed session before any compaction telemetry exists. (These
1529
- // used to reference undefined compactTrigger/compactBoundary and threw a
1530
- // ReferenceError when the picker opened.)
1531
- const compactBoundary = Number(compaction.boundaryTokens || windowTokens || 0);
1532
- const compactTrigger = Number(compaction.triggerTokens || compactBoundary || 0);
1533
- const usedTokens = Number(context.usedTokens || context.currentEstimatedTokens || usage.lastContextTokens || 0);
1534
- const freeTokens = windowTokens ? Math.max(0, windowTokens - usedTokens) : Number(context.freeTokens || 0);
1535
- const pct = (value, total = windowTokens) => {
1536
- const n = Number(value || 0);
1537
- const d = Number(total || 0);
1538
- if (!d) return 'N/A';
1539
- const p = Math.max(0, Math.min(100, (n / d) * 100));
1540
- return `${p > 0 && p < 1 ? p.toFixed(1) : Math.floor(p)}%`;
1541
- };
1542
- const fmt = (value) => {
1543
- const n = Number(value || 0);
1544
- if (!Number.isFinite(n) || n <= 0) return '0';
1545
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
1546
- if (n >= 10_000) return `${Math.round(n / 1000)}k`;
1547
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
1548
- return `${Math.round(n)}`;
1549
- };
1550
- const cachedRead = Number(usage.lastCachedReadTokens || 0);
1551
- const cacheWrite = Number(usage.lastCacheWriteTokens || 0);
1552
- const freshInput = Number(
1553
- usage.lastUncachedInputTokens != null
1554
- ? usage.lastUncachedInputTokens
1555
- : Math.max(Number(usage.lastInputTokens || 0) - cachedRead - cacheWrite, 0)
1556
- );
1557
- const cacheDenom = Number(usage.lastContextTokens || 0) || (cachedRead + freshInput + cacheWrite);
1558
- const cacheHitRate = cacheDenom > 0
1559
- ? `${((cachedRead / cacheDenom) * 100).toFixed(0)}%`
1560
- : 'N/A';
1561
- const cacheWriteLabel = cacheWrite > 0 ? ` · ${fmt(cacheWrite)} write` : '';
1562
- const contextSource = context.usedSource === 'last_api_request' ? 'last API request' : 'estimated';
1563
- const lastApiLabel = context.lastApiRequestStale ? 'last API request (pre-compact)' : 'last API request';
1564
- const compactElapsed = (value) => {
1565
- const n = Number(value || 0);
1566
- if (!Number.isFinite(n) || n <= 0) return '';
1567
- return `${Math.max(1, Math.ceil(n / 1000))}s`;
1568
- };
1569
- const compactRunning = compaction.inProgress === true || compaction.lastStage === 'compacting';
1570
- const autoClearFailed = compaction.lastStage === 'auto_clear_failed' || !!compaction.lastClearCompactError;
1571
- const autoClearStage = compaction.lastStage === 'auto_clear' || compaction.lastClearAt;
1572
- const compactDuration = compactElapsed(compaction.lastDurationMs);
1573
- const compactInterrupted = compaction.lastStage === 'interrupted';
1574
- const compactReactive = String(compaction.lastTrigger || '').toLowerCase() === 'reactive';
1575
- const compactState = compactRunning
1576
- ? 'Compacting conversation'
1577
- : compactInterrupted
1578
- ? 'Compact interrupted'
1579
- : autoClearFailed
1580
- ? `auto-clear skipped${compaction.lastClearCompactError ? `: ${compaction.lastClearCompactError}` : ''}`
1581
- : autoClearStage
1582
- ? 'Auto-clear complete'
1583
- : compaction.lastChanged
1584
- ? (compactReactive ? 'Compact complete (overflow recovery)' : 'Compact complete')
1585
- : 'Compact checked';
1586
- const compactDescription = compactDuration
1587
- ? `${compactState} · ${compactDuration}`
1588
- : compactState;
1589
- const contextRows = [
1590
- {
1591
- value: 'summary',
1592
- label: 'Context Usage',
1593
- description: `${fmt(usedTokens)}/${fmt(windowTokens)} (${pct(usedTokens)}) · ${fmt(freeTokens)} free · ${contextSource} · effective`,
1594
- _action: 'summary',
1595
- },
1596
- {
1597
- value: 'compaction',
1598
- label: 'Compaction',
1599
- description: compactDescription,
1600
- _action: 'compaction',
1601
- },
1602
- {
1603
- value: 'messages',
1604
- label: 'Messages',
1605
- description: `${fmt(messages.estimatedTokens)} tokens (${pct(messages.estimatedTokens)}) · ${messages.count || 0} messages`,
1606
- _action: 'messages',
1607
- },
1608
- {
1609
- value: 'tools',
1610
- label: 'Tools',
1611
- description: `${fmt(builtInToolSchemaTokens)} schema tokens (${pct(builtInToolSchemaTokens)}) · ${tools.activeCount || 0}/${tools.count || 0} active`,
1612
- _action: 'tools',
1613
- },
1614
- {
1615
- value: 'tool-io',
1616
- label: 'Tool calls/results',
1617
- description: `${messages.toolCallCount || 0} calls (${fmt(messages.toolCallTokens)}) · ${messages.toolResultCount || 0} results (${fmt(messages.toolResultTokens)})`,
1618
- _action: 'tool-io',
1619
- },
1620
- {
1621
- value: 'request',
1622
- label: 'Request overhead',
1623
- description: `${fmt(request.requestOverheadTokens)} framing · ${fmt(request.reserveTokens)} reserve incl. tools`,
1624
- _action: 'request',
1625
- },
1626
- {
1627
- value: 'last-api',
1628
- label: 'Last API usage',
1629
- description: `${fmt(usage.lastContextTokens)} context · ${fmt(freshInput)} uncached input · ${fmt(usage.lastOutputTokens)} output · ${lastApiLabel}`,
1630
- _action: 'last-api',
1631
- },
1632
- {
1633
- value: 'cache',
1634
- label: 'Prompt cache',
1635
- description: `${cacheHitRate} hit · ${fmt(usage.lastCachedReadTokens)} read${cacheWriteLabel} · ${fmt(freshInput)} new (last request)`,
1636
- _action: 'cache',
1637
- },
1638
- {
1639
- value: 'free',
1640
- label: 'Free space',
1641
- description: `${fmt(freeTokens)} tokens (${pct(freeTokens)}) · raw window ${fmt(rawWindowTokens)}`,
1642
- _action: 'free',
1643
- },
1644
- {
1645
- value: 'extensions',
1646
- label: 'Skills/plugins',
1647
- description: `${skills.count || 0} skills · ${plugins.count || 0} plugins`,
1648
- _action: 'extensions',
1649
- },
1650
- ];
1651
- setProviderPrompt(null);
1652
- setChannelPrompt(null);
1653
- setHookPrompt(null);
1654
- setSettingsPrompt(null);
1655
- setPicker(null);
1656
- setContextPanel({
1657
- kind: 'context',
1658
- title: 'Context Usage',
1659
- detail: {
1660
- type: 'context',
1661
- usage: {
1662
- usedTokens,
1663
- windowTokens,
1664
- freeTokens,
1665
- rawWindowTokens,
1666
- source: contextSource,
1667
- effective: true,
1668
- },
1669
- compaction: {
1670
- stage: compaction.lastStage || 'pending',
1671
- state: compactState,
1672
- triggerTokens: compactTrigger,
1673
- boundaryTokens: compactBoundary,
1674
- type: compaction.compactType || compaction.type || null,
1675
- bufferTokens: Number(compaction.bufferTokens || (compactBoundary && compactTrigger ? Math.max(0, compactBoundary - compactTrigger) : 0)) || null,
1676
- pressureTokens: Number(compaction.lastPressureTokens || compaction.currentEstimatedTokens || 0) || null,
1677
- lastChanged: compaction.lastChanged === true,
1678
- },
1679
- messages: {
1680
- tokens: messages.estimatedTokens,
1681
- count: messages.count,
1682
- semantic: messages.semantic,
1683
- },
1684
- tools: {
1685
- schemaTokens: builtInToolSchemaTokens,
1686
- active: tools.activeCount,
1687
- count: tools.count,
1688
- },
1689
- toolIo: {
1690
- calls: messages.toolCallCount,
1691
- results: messages.toolResultCount,
1692
- },
1693
- request: {
1694
- toolSchemaBreakdown: request.toolSchemaBreakdown,
1695
- overheadTokens: request.requestOverheadTokens,
1696
- reserveTokens: request.reserveTokens,
1697
- },
1698
- lastApi: {
1699
- contextTokens: usage.lastContextTokens,
1700
- inputTokens: freshInput,
1701
- rawInputTokens: usage.lastInputTokens,
1702
- outputTokens: usage.lastOutputTokens,
1703
- },
1704
- cache: {
1705
- hitRate: cacheHitRate,
1706
- readTokens: usage.lastCachedReadTokens,
1707
- writeTokens: cacheWrite,
1708
- },
1709
- extensions: {
1710
- skills: skills.count,
1711
- plugins: plugins.count,
1712
- },
1713
- mcp: {
1714
- connected: mcp.connectedCount,
1715
- configured: mcp.configuredCount,
1716
- failed: mcp.failedCount,
1717
- tools: tools.mcpToolCount,
1718
- activeTools: tools.activeMcpToolCount,
1719
- schemaTokens: mcpToolSchemaTokens,
1720
- },
1721
- },
1722
- rows: contextRows,
1723
- });
1724
- };
874
+ // Usage-quota dashboard + /context breakdown panels:
875
+ // app/usage-context-panels.mjs (same factory pattern as the pickers).
876
+ const { openUsagePanel, openContextPicker } = createUsageContextPanels({
877
+ store,
878
+ state,
879
+ usageRequestRef,
880
+ setPicker,
881
+ setProviderPrompt,
882
+ setChannelPrompt,
883
+ setHookPrompt,
884
+ setSettingsPrompt,
885
+ setContextPanel,
886
+ setUsagePanel,
887
+ closeUsagePanel,
888
+ });
1725
889
 
1726
890
  useEffect(() => {
1727
891
  if (contextPanel?.kind === 'context') {
@@ -1765,446 +929,40 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1765
929
  };
1766
930
  }, [store, forceOnboarding]);
1767
931
 
1768
- const onSubmit = (raw) => {
1769
- const text = String(raw ?? '');
1770
- const commandText = text.trim();
1771
- if (providerPrompt) {
1772
- if (state.commandBusy) {
1773
- store.pushNotice('wait for the current command to finish', 'warn');
1774
- return false;
1775
- }
1776
- if (providerPrompt.kind === 'api-key') {
1777
- if (!commandText) {
1778
- store.pushNotice(`API key is required for ${providerPrompt.providerId}`, 'warn');
1779
- return false;
1780
- }
1781
- try {
1782
- store.saveProviderApiKey(providerPrompt.providerId, commandText);
1783
- clearModelCaches('all');
1784
- const afterSave = providerPrompt.afterSave;
1785
- setProviderPrompt(null);
1786
- if (afterSave) afterSave();
1787
- else void openProviderSetupPicker();
1788
- return true;
1789
- } catch (e) {
1790
- store.pushNotice(`api key save failed: ${e?.message || e}`, 'error');
1791
- return false;
1792
- }
1793
- }
1794
- if (providerPrompt.kind === 'openai-usage-session') {
1795
- if (!commandText) {
1796
- store.pushNotice('OpenAI usage session key is required for credit lookup', 'warn');
1797
- return false;
1798
- }
1799
- try {
1800
- store.saveOpenAIUsageSessionKey(commandText);
1801
- const afterSave = providerPrompt.afterSave;
1802
- setProviderPrompt(null);
1803
- if (afterSave) afterSave();
1804
- else void openProviderSetupPicker();
1805
- return true;
1806
- } catch (e) {
1807
- store.pushNotice(`OpenAI usage auth save failed: ${e?.message || e}`, 'error');
1808
- return false;
1809
- }
1810
- }
1811
- if (providerPrompt.kind === 'local-url') {
1812
- try {
1813
- store.setLocalProvider(providerPrompt.providerId, {
1814
- enabled: true,
1815
- baseURL: commandText || providerPrompt.defaultURL,
1816
- });
1817
- clearModelCaches('all');
1818
- const afterSave = providerPrompt.afterSave;
1819
- setProviderPrompt(null);
1820
- if (afterSave) afterSave();
1821
- else void openProviderSetupPicker();
1822
- return true;
1823
- } catch (e) {
1824
- store.pushNotice(`local provider update failed: ${e?.message || e}`, 'error');
1825
- return false;
1826
- }
1827
- }
1828
- if (providerPrompt.kind === 'oauth-code') {
1829
- if (!commandText) {
1830
- store.pushNotice('OAuth code is required', 'warn');
1831
- return false;
1832
- }
1833
- if (oauthSubmitRef.current || providerPrompt.submitting) {
1834
- store.pushNotice('OAuth code is already being submitted', 'warn');
1835
- return false;
1836
- }
1837
- oauthSubmitRef.current = true;
1838
- setProviderPrompt((prompt) => prompt === providerPrompt ? { ...prompt, submitting: true } : prompt);
1839
- void providerPrompt.login?.completeCode(commandText)
1840
- .then(() => {
1841
- const successReturn = providerPrompt.successReturn;
1842
- const afterSave = providerPrompt.afterSave;
1843
- oauthSubmitRef.current = false;
1844
- clearModelCaches('all');
1845
- setProviderPrompt(null);
1846
- store.pushNotice(`${providerPrompt.providerName || 'OAuth'} login complete`, 'info');
1847
- if (successReturn) successReturn();
1848
- else if (afterSave) afterSave();
1849
- else void openProviderSetupPicker();
1850
- })
1851
- .catch((e) => {
1852
- oauthSubmitRef.current = false;
1853
- store.pushNotice(`oauth code failed: ${e?.message || e}`, 'error');
1854
- setProviderPrompt(null);
1855
- providerPrompt.failureReturn?.(e);
1856
- });
1857
- return true;
1858
- }
1859
- }
1860
- if (channelPrompt) {
1861
- if (state.commandBusy) {
1862
- store.pushNotice('wait for the current command to finish', 'warn');
1863
- return false;
1864
- }
1865
- try {
1866
- const resumeAfterChannelPrompt = (prompt) => {
1867
- const afterSave = prompt?.afterSave;
1868
- setChannelPrompt(null);
1869
- if (typeof afterSave === 'function') afterSave();
1870
- else void openChannelSetupPicker('all');
1871
- };
1872
- if (channelPrompt.kind === 'discord-token') {
1873
- if (!commandText) return false;
1874
- store.saveDiscordToken(commandText);
1875
- resumeAfterChannelPrompt(channelPrompt);
1876
- return true;
1877
- }
1878
- if (channelPrompt.kind === 'telegram-token') {
1879
- if (!commandText) return false;
1880
- store.saveTelegramToken(commandText);
1881
- resumeAfterChannelPrompt(channelPrompt);
1882
- return true;
1883
- }
1884
- const parts = commandText.split('|').map((part) => part.trim());
1885
- if (channelPrompt.kind === 'channel-add') {
1886
- // Single-channel: the UI asks only for the channel id. Legacy
1887
- // `name | id | ...` pipe input still parses (the id is the second
1888
- // field) so old muscle memory does not break.
1889
- const isPipe = parts.length > 1;
1890
- const channelId = isPipe ? parts[1] : parts[0];
1891
- Promise.resolve(store.setChannel({
1892
- channelId,
1893
- backend: channelPrompt.backend,
1894
- }))
1895
- .then(() => resumeAfterChannelPrompt(channelPrompt))
1896
- .catch((e) => {
1897
- store.pushNotice(`channel save failed: ${e?.message || e}`, 'error');
1898
- });
1899
- return true;
1900
- }
1901
- // schedule-add / webhook-add prompt kinds retired: schedules and
1902
- // webhooks are managed in the desktop app (user decision).
1903
- } catch (e) {
1904
- store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
1905
- return false;
1906
- }
1907
- }
1908
- if (hookPrompt) {
1909
- if (state.commandBusy) {
1910
- store.pushNotice('wait for the current command to finish', 'warn');
1911
- return false;
1912
- }
1913
- try {
1914
- if (hookPrompt.kind === 'rule-add') {
1915
- const parsed = parseHookRuleInput(commandText);
1916
- if (parsed.error) {
1917
- store.pushNotice(parsed.error, 'warn');
1918
- return false;
1919
- }
1920
- store.addHookRule?.(parsed.rule);
1921
- setHookPrompt(null);
1922
- void openHooksPicker();
1923
- return true;
1924
- }
1925
- } catch (e) {
1926
- store.pushNotice(`hook update failed: ${e?.message || e}`, 'error');
1927
- return false;
1928
- }
1929
- }
1930
- if (settingsPrompt) {
1931
- if (state.commandBusy) {
1932
- store.pushNotice('wait for the current command to finish', 'warn');
1933
- return false;
1934
- }
1935
- try {
1936
- if (settingsPrompt.kind === 'cwd') {
1937
- if (!commandText) {
1938
- store.pushNotice('working directory path is required', 'warn');
1939
- return false;
1940
- }
1941
- store.setCwd?.(commandText, { message: `Project set: ${projectNameFromPath(commandText)}` });
1942
- setSettingsPrompt(null);
1943
- void openSettingsPicker();
1944
- return true;
1945
- }
1946
- if (settingsPrompt.kind === 'project-new') {
1947
- if (!commandText) {
1948
- store.pushNotice('project path is required', 'warn');
1949
- return false;
1950
- }
1951
- const path = resolveProjectPath(commandText);
1952
- if (isDirectory(path)) {
1953
- setSettingsPrompt(null);
1954
- registerProject(path);
1955
- return true;
1956
- }
1957
- // A path that exists but is a regular file is not a valid project dir.
1958
- if (pathExists(path)) {
1959
- store.pushNotice(`${path} is not a directory`, 'warn');
1960
- return false;
1961
- }
1962
- // Missing folder: confirm creation before registering.
1963
- setSettingsPrompt({
1964
- kind: 'project-create-confirm',
1965
- label: 'New project · Create folder?',
1966
- hint: `${path} does not exist. Type "y" to create it, or anything else to cancel.`,
1967
- pendingPath: path,
1968
- });
1969
- return true;
1970
- }
1971
- if (settingsPrompt.kind === 'project-create-confirm') {
1972
- const pendingPath = String(settingsPrompt.pendingPath || '');
1973
- const answer = String(commandText || '').trim().toLowerCase();
1974
- if (answer === 'y' || answer === 'yes') {
1975
- const created = ensureDir(pendingPath);
1976
- if (!created) {
1977
- store.pushNotice(`could not create folder: ${pendingPath}`, 'error');
1978
- setSettingsPrompt(null);
1979
- return true;
1980
- }
1981
- setSettingsPrompt(null);
1982
- registerProject(pendingPath);
1983
- return true;
1984
- }
1985
- setSettingsPrompt(null);
1986
- store.pushNotice('project creation canceled', 'info');
1987
- return true;
1988
- }
1989
- if (settingsPrompt.kind === 'project-rename') {
1990
- const targetPath = String(settingsPrompt.projectPath || '');
1991
- try {
1992
- const updated = renameProject(targetPath, commandText);
1993
- if (updated) {
1994
- store.pushNotice(`project renamed to "${updated.name}"`, 'info');
1995
- }
1996
- } catch (e) {
1997
- store.pushNotice(`rename failed: ${e?.message || e}`, 'error');
1998
- }
1999
- setSettingsPrompt(null);
2000
- openProjectPicker();
2001
- return true;
2002
- }
2003
- if (settingsPrompt.kind === 'system-shell') {
2004
- store.setSystemShell?.(commandText);
2005
- setSettingsPrompt(null);
2006
- void openSettingsPicker();
2007
- return true;
2008
- }
2009
- if (settingsPrompt.kind === 'autoclear-provider') {
2010
- const provider = String(settingsPrompt.provider || '').trim();
2011
- if (!provider) {
2012
- store.pushNotice('auto-clear provider is missing', 'warn');
2013
- return false;
2014
- }
2015
- const text = String(commandText || '').trim();
2016
- try {
2017
- if (text) store.setAutoClear?.({ provider, duration: text });
2018
- else store.setAutoClear?.({ provider, resetProvider: true });
2019
- store.pushNotice(text ? `Auto-clear ${provider} default set to ${text}` : `Auto-clear ${provider} default reset`, 'info');
2020
- } catch (e) {
2021
- store.pushNotice(`autoclear failed: ${e?.message || e}`, 'error');
2022
- return false;
2023
- }
2024
- setSettingsPrompt(null);
2025
- openAutoClearPicker({ advanced: true, returnTo: settingsPrompt.returnTo });
2026
- return true;
2027
- }
2028
- if (settingsPrompt.kind === 'profile-title') {
2029
- try {
2030
- store.setProfile?.({ title: commandText });
2031
- store.pushNotice(commandText ? `Title set to "${commandText.trim()}"` : 'Title cleared', 'info');
2032
- } catch (e) {
2033
- store.pushNotice(`profile update failed: ${e?.message || e}`, 'error');
2034
- }
2035
- setSettingsPrompt(null);
2036
- openProfilePicker();
2037
- return true;
2038
- }
2039
- if (settingsPrompt.kind === 'plugin-add') {
2040
- if (!commandText) {
2041
- store.pushNotice('plugin URL/path is required', 'warn');
2042
- return false;
2043
- }
2044
- void store.addPlugin?.(commandText)
2045
- .then(() => openPluginsPicker())
2046
- .catch((e) => store.pushNotice(`plugin add failed: ${e?.message || e}`, 'error'));
2047
- setSettingsPrompt(null);
2048
- return true;
2049
- }
2050
- if (settingsPrompt.kind === 'mcp-add') {
2051
- const parsed = parseMcpServerInput(commandText);
2052
- if (parsed.error) {
2053
- store.pushNotice(parsed.error, 'warn');
2054
- return false;
2055
- }
2056
- void store.addMcpServer?.(parsed.server)
2057
- .then(() => openMcpServersPicker())
2058
- .catch((e) => store.pushNotice(`mcp add failed: ${e?.message || e}`, 'error'));
2059
- setSettingsPrompt(null);
2060
- return true;
2061
- }
2062
- if (settingsPrompt.kind === 'skill-add') {
2063
- const parsed = parseSkillInput(commandText);
2064
- if (parsed.error) {
2065
- store.pushNotice(parsed.error, 'warn');
2066
- return false;
2067
- }
2068
- void store.addSkill?.(parsed.skill)
2069
- .then(() => openProjectSkillsPicker())
2070
- .catch((e) => store.pushNotice(`skill add failed: ${e?.message || e}`, 'error'));
2071
- setSettingsPrompt(null);
2072
- return true;
2073
- }
2074
- if (settingsPrompt.kind === 'skill-use') {
2075
- const skillName = String(settingsPrompt.skillName || '').trim();
2076
- if (!skillName) {
2077
- store.pushNotice('skill name is missing', 'warn');
2078
- return false;
2079
- }
2080
- const prompt = `$${skillName}${commandText ? ` ${commandText}` : ''}`;
2081
- setSettingsPrompt(null);
2082
- const accepted = store.submit(prompt);
2083
- if (accepted) armTranscriptFollow();
2084
- return accepted;
2085
- }
2086
- if (settingsPrompt.kind === 'core-add') {
2087
- const sentence = commandText.trim();
2088
- if (!sentence) {
2089
- store.pushNotice('memory sentence is required', 'warn');
2090
- return false;
2091
- }
2092
- setSettingsPrompt(null);
2093
- void store.memoryControl?.({ action: 'core', op: 'add', project_id: 'common', element: sentence, summary: sentence }, { silent: true })
2094
- .then((result) => {
2095
- const errText = memoryCoreResultErrorText(result);
2096
- store.pushNotice(errText || 'core memory added', errText ? 'error' : 'info');
2097
- openMemoryCorePicker();
2098
- })
2099
- .catch((e) => {
2100
- store.pushNotice(`core add failed: ${e?.message || e}`, 'error');
2101
- openMemoryCorePicker();
2102
- });
2103
- return true;
2104
- }
2105
- if (settingsPrompt.kind === 'core-edit') {
2106
- const sentence = commandText.trim();
2107
- const id = settingsPrompt._id;
2108
- const projectId = settingsPrompt._projectId ?? 'common';
2109
- if (!sentence) {
2110
- store.pushNotice('memory sentence is required', 'warn');
2111
- return false;
2112
- }
2113
- setSettingsPrompt(null);
2114
- // Single-sentence semantics only rewrite `element` when the row was
2115
- // already element===summary at load (see beginEditCoreMemory's
2116
- // _singleSentence flag). A distinct legacy element carries meaning
2117
- // this text prompt never captured -- clobbering it on every edit
2118
- // would corrupt the entry (and re-embed/dedupe on the clobbered
2119
- // value). Otherwise only `summary` is sent.
2120
- const editArgs = settingsPrompt._singleSentence
2121
- ? { action: 'core', op: 'edit', id, project_id: projectId, element: sentence, summary: sentence }
2122
- : { action: 'core', op: 'edit', id, project_id: projectId, summary: sentence };
2123
- void store.memoryControl?.(editArgs, { silent: true })
2124
- .then((result) => {
2125
- const errText = memoryCoreResultErrorText(result);
2126
- store.pushNotice(errText || 'core memory updated', errText ? 'error' : 'info');
2127
- openMemoryCorePicker();
2128
- })
2129
- .catch((e) => {
2130
- store.pushNotice(`core edit failed: ${e?.message || e}`, 'error');
2131
- openMemoryCorePicker();
2132
- });
2133
- return true;
2134
- }
2135
- if (settingsPrompt.kind === 'core-delete-confirm') {
2136
- const id = settingsPrompt._id;
2137
- const projectId = settingsPrompt._projectId ?? 'common';
2138
- const answer = String(commandText || '').trim().toLowerCase();
2139
- setSettingsPrompt(null);
2140
- if (answer !== 'y' && answer !== 'yes') {
2141
- store.pushNotice('delete canceled', 'info');
2142
- openMemoryCorePicker();
2143
- return true;
2144
- }
2145
- void store.memoryControl?.({ action: 'core', op: 'delete', id, project_id: projectId }, { silent: true })
2146
- .then((result) => {
2147
- const errText = memoryCoreResultErrorText(result);
2148
- store.pushNotice(errText || 'core memory deleted', errText ? 'error' : 'info');
2149
- openMemoryCorePicker();
2150
- })
2151
- .catch((e) => {
2152
- store.pushNotice(`core delete failed: ${e?.message || e}`, 'error');
2153
- openMemoryCorePicker();
2154
- });
2155
- return true;
2156
- }
2157
- } catch (e) {
2158
- store.pushNotice(`settings update failed: ${e?.message || e}`, 'error');
2159
- return false;
2160
- }
2161
- }
2162
- if (!commandText) return false;
2163
-
2164
- if (commandText.startsWith('/')) {
2165
- if (state.commandBusy) {
2166
- store.pushNotice('wait for the current command to finish', 'warn');
2167
- return false;
2168
- }
2169
- const [cmd, ...rest] = commandText.slice(1).split(/\s+/);
2170
- const accepted = runSlashCommand(cmd, rest.join(' ').trim());
2171
- if (accepted !== false) clearPastedImagesSnapshot();
2172
- return accepted;
2173
- }
2174
- const imageRefs = imageReferenceIds(text);
2175
- const imageSnapshot = Object.fromEntries(Object.entries(pastedImagesRef.current || {})
2176
- .filter(([id]) => imageRefs.has(Number(id))));
2177
- const hasImageSnapshot = Object.keys(imageSnapshot).length > 0;
2178
- // Expand folded [Pasted text #N +M lines] tokens back to their original
2179
- // text at the same point buildPromptContentWithImages runs. Broken /
2180
- // partially-deleted tokens do not match and are left as-is.
2181
- const textRefs = pastedTextReferenceIds(text);
2182
- const textSnapshot = Object.fromEntries(Object.entries(pastedTextsRef.current || {})
2183
- .filter(([id]) => textRefs.has(Number(id))));
2184
- const hasTextSnapshot = Object.keys(textSnapshot).length > 0;
2185
- const expandedText = hasTextSnapshot ? expandPastedTextTokens(text, textSnapshot) : text;
2186
- const content = buildPromptContentWithImages(expandedText, imageSnapshot);
2187
- const accepted = store.submit(content, {
2188
- // Store the EXPANDED text in the transcript/history so a later prompt-
2189
- // history recall resubmits the real content, not the literal token
2190
- // (pastedTexts entries are cleared on accept). History recall therefore
2191
- // shows the full original text instead of the token — acceptable.
2192
- displayText: expandedText,
2193
- pastedImages: imageSnapshot,
2194
- pastedTexts: textSnapshot,
2195
- onCommitted: (hasImageSnapshot || hasTextSnapshot)
2196
- ? () => { clearPastedImagesSnapshot(imageSnapshot); clearPastedTextsSnapshot(textSnapshot); }
2197
- : null,
2198
- });
2199
- if (accepted) {
2200
- armTranscriptFollow();
2201
- if (imageRefs.size === 0 || (!hasImageSnapshot && !state.busy)) clearPastedImagesSnapshot();
2202
- else if (state.busy && hasImageSnapshot) clearPastedImagesSnapshot(imageSnapshot);
2203
- if (textRefs.size === 0 || (!hasTextSnapshot && !state.busy)) clearPastedTextsSnapshot();
2204
- else if (state.busy && hasTextSnapshot) clearPastedTextsSnapshot(textSnapshot);
2205
- }
2206
- return accepted;
2207
- };
932
+ // Prompt submit dispatcher (text-entry prompts, slash commands, chat
933
+ // submit + pasted-token expansion): app/prompt-submit.mjs.
934
+ const { onSubmit } = createPromptSubmit({
935
+ store,
936
+ state,
937
+ providerPrompt,
938
+ channelPrompt,
939
+ hookPrompt,
940
+ settingsPrompt,
941
+ setProviderPrompt,
942
+ setChannelPrompt,
943
+ setHookPrompt,
944
+ setSettingsPrompt,
945
+ oauthSubmitRef,
946
+ clearModelCaches,
947
+ openProviderSetupPicker,
948
+ openChannelSetupPicker,
949
+ openHooksPicker,
950
+ openSettingsPicker,
951
+ openProjectPicker,
952
+ openAutoClearPicker,
953
+ openProfilePicker,
954
+ openPluginsPicker,
955
+ openMcpServersPicker,
956
+ openProjectSkillsPicker,
957
+ openMemoryCorePicker,
958
+ registerProject,
959
+ runSlashCommand,
960
+ armTranscriptFollow,
961
+ clearPastedImagesSnapshot,
962
+ clearPastedTextsSnapshot,
963
+ pastedImagesRef,
964
+ pastedTextsRef,
965
+ });
2208
966
 
2209
967
  const activeSlashQuery = providerPrompt || channelPrompt || hookPrompt || settingsPrompt || toolApproval || contextPanel || usagePanel ? null : slashQuery(promptDraft);
2210
968
  // "Slash mode" is live whenever a /token is being edited and no other
@@ -2236,663 +994,113 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2236
994
  setSlashIndex((index) => Math.min(index, Math.max(0, slashCommands.length - 1)));
2237
995
  }, [slashCommands.length, activeSlashQuery]);
2238
996
 
2239
- const onPromptDraftChange = useCallback((value) => {
2240
- if (String(value ?? '').length > 0) dismissWelcomePromptHint();
2241
- syncPromptLayoutRows(value);
2242
- // NOTE: do NOT prune pasted-text entries on edit. A partially-edited token
2243
- // can be undone back to its intact form, which must still expand on submit;
2244
- // entries are kept until an accepted submit or an explicit clear. (Memory
2245
- // cost is bounded and acceptable.)
2246
- const suppressPromptHint = promptHistoryDraftChangeRef.current;
2247
- promptHistoryDraftChangeRef.current = false;
2248
- const historyNav = promptHistoryNavRef.current;
2249
- if (!value || (historyNav.active && value !== historyNav.lastValue && value !== historyNav.seed)) {
2250
- resetPromptHistoryNav();
2251
- }
2252
- // Only lift the draft into App state when it can affect the slash palette
2253
- // (a single "/token"). Prose typing renders entirely inside PromptInput's
2254
- // own state, so App need not re-render — and relayout the full fullscreen
2255
- // frame — on every keystroke (input lag fix). Entering slash mode and
2256
- // leaving it both still sync because either prev or next is a slash token.
2257
- // Clearing/submitting must also sync so a consumed slash command does not
2258
- // remount later as stale initialValue after a picker/panel closes.
2259
- const nextSlash = slashQuery(value);
2260
- setPromptDraft((prev) => {
2261
- const previousWasSlashFlow = String(prev || '').startsWith('/');
2262
- if (value === '') return '';
2263
- return nextSlash !== null || previousWasSlashFlow ? value : prev;
2264
- });
2265
- setPromptDraftOverride((prev) => (prev === null ? prev : null));
2266
- const argumentHint = slashArgumentHint(value);
2267
- if (argumentHint && !suppressPromptHint) {
2268
- showPromptHint(argumentHint, 'info');
2269
- } else if (suppressPromptHint || promptHintActiveRef.current || promptHintTimerRef.current) {
2270
- // Only clear when a hint is actually live (shown or pending its timer).
2271
- // clearPromptHint() already early-returns when neither ref is set, but
2272
- // gating the call here avoids invoking it on EVERY keystroke once a hint
2273
- // has appeared — that call path otherwise drives a setState → full App
2274
- // re-render per key, which is costly on long transcripts. Hint-while-
2275
- // typing still vanishes immediately because the guard includes the active
2276
- // state; the argumentHint branch above is untouched. The guard no longer
2277
- // requires a non-empty value: clearing/submitting to '' must also dismiss
2278
- // a live hint instead of leaving it until its timer expires.
2279
- clearPromptHint();
2280
- }
2281
- if (slashDismissedFor) {
2282
- setSlashDismissedFor((dismissed) => (dismissed && dismissed !== value ? '' : dismissed));
2283
- }
2284
- }, [clearPromptHint, dismissWelcomePromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor, syncPromptLayoutRows]);
2285
-
2286
- const cancelProviderPrompt = useCallback(() => {
2287
- try { providerPrompt?.login?.cancel?.(); } catch {}
2288
- oauthSubmitRef.current = false;
2289
- const onCancel = providerPrompt?.cancelReturn || providerPrompt?.onCancel;
2290
- const afterSave = providerPrompt?.afterSave;
2291
- setProviderPrompt(null);
2292
- if (onCancel) onCancel();
2293
- else if (afterSave) afterSave();
2294
- }, [providerPrompt, showPromptHint]);
2295
-
2296
- const cancelChannelPrompt = useCallback(() => {
2297
- const onCancel = channelPrompt?.onCancel;
2298
- const afterSave = channelPrompt?.afterSave;
2299
- setChannelPrompt(null);
2300
- if (typeof onCancel === 'function') onCancel();
2301
- else if (typeof afterSave === 'function') afterSave();
2302
- }, [channelPrompt, showPromptHint]);
2303
-
2304
- const cancelHookPrompt = useCallback(() => {
2305
- setHookPrompt(null);
2306
- }, [showPromptHint]);
2307
-
2308
- const cancelSettingsPrompt = useCallback(() => {
2309
- // The project entry prompts are reached from the project picker; backing out
2310
- // (Esc) should return to that picker rather than dropping to a bare prompt.
2311
- const kind = settingsPrompt?.kind;
2312
- setSettingsPrompt(null);
2313
- if (kind === 'project-new' || kind === 'project-create-confirm' || kind === 'project-rename') {
2314
- openProjectPicker();
2315
- } else if (kind === 'core-add' || kind === 'core-edit' || kind === 'core-delete-confirm') {
2316
- openMemoryCorePicker();
2317
- } else if (kind === 'autoclear-provider') {
2318
- openAutoClearPicker({ advanced: true, returnTo: settingsPrompt?.returnTo });
2319
- }
2320
- }, [settingsPrompt, showPromptHint]);
2321
-
2322
- const acceptSlashPalette = useCallback((draftValue = '') => {
2323
- const command = slashCommands[slashIndex];
2324
- if (!command) return false;
2325
- pickerOpenedFromEnterRef.current = true;
2326
- if (pickerOpenedFromEnterTimerRef.current) {
2327
- clearTimeout(pickerOpenedFromEnterTimerRef.current);
2328
- pickerOpenedFromEnterTimerRef.current = null;
2329
- }
2330
- try {
2331
- return runSlashCommand(slashCommandTokenForPaletteAccept(command, draftValue), '');
2332
- } finally {
2333
- pickerOpenedFromEnterTimerRef.current = setTimeout(() => {
2334
- pickerOpenedFromEnterRef.current = false;
2335
- pickerOpenedFromEnterTimerRef.current = null;
2336
- }, 3000);
2337
- }
2338
- }, [slashCommands, slashIndex]);
2339
-
2340
- const completeSlashPalette = useCallback((draftValue = '') => {
2341
- const command = slashCommands[slashIndex];
2342
- if (!command) return undefined;
2343
- const token = slashCommandTokenForPaletteAccept(command, draftValue);
2344
- return token ? `/${token} ` : undefined;
2345
- }, [slashCommands, slashIndex]);
2346
-
2347
- const cancelSlashPalette = useCallback((value = '') => {
2348
- // Esc clears the slash draft, so the dismissal marker must not survive.
2349
- // If it stays as "/" then typing "/" again is treated as the same
2350
- // dismissed query and the palette never re-opens.
2351
- setSlashDismissedFor('');
2352
- setPromptDraft('');
2353
- setPromptDraftOverride({ id: Date.now(), value: '' });
2354
- }, []);
997
+ // Draft/slash flow (draft sync, prompt cancels, palette accept/cancel):
998
+ // app/use-prompt-draft-flow.mjs.
999
+ const {
1000
+ onPromptDraftChange,
1001
+ cancelProviderPrompt,
1002
+ cancelChannelPrompt,
1003
+ cancelHookPrompt,
1004
+ cancelSettingsPrompt,
1005
+ acceptSlashPalette,
1006
+ completeSlashPalette,
1007
+ cancelSlashPalette,
1008
+ } = usePromptDraftFlow({
1009
+ dismissWelcomePromptHint,
1010
+ syncPromptLayoutRows,
1011
+ promptHistoryDraftChangeRef,
1012
+ promptHistoryNavRef,
1013
+ resetPromptHistoryNav,
1014
+ setPromptDraft,
1015
+ setPromptDraftOverride,
1016
+ showPromptHint,
1017
+ clearPromptHint,
1018
+ promptHintActiveRef,
1019
+ promptHintTimerRef,
1020
+ slashDismissedFor,
1021
+ setSlashDismissedFor,
1022
+ providerPrompt,
1023
+ channelPrompt,
1024
+ hookPrompt,
1025
+ settingsPrompt,
1026
+ setProviderPrompt,
1027
+ setChannelPrompt,
1028
+ setHookPrompt,
1029
+ setSettingsPrompt,
1030
+ oauthSubmitRef,
1031
+ openProjectPicker,
1032
+ openMemoryCorePicker,
1033
+ openAutoClearPicker,
1034
+ slashCommands,
1035
+ slashIndex,
1036
+ pickerOpenedFromEnterRef,
1037
+ pickerOpenedFromEnterTimerRef,
1038
+ runSlashCommand,
1039
+ });
2355
1040
 
2356
1041
  const resizeEpoch = resizeState.epoch;
2357
- // agentRevision is a cheap change-detection key for downstream consumers, but
2358
- // JSON.stringify over the worker/job arrays ran on EVERY render (including the
2359
- // ~120fps streaming reconciles). Memoize on the agent slices so it only
2360
- // recomputes when agent state actually changes, not on every assistant delta.
2361
- const agentRevision = useMemo(() => JSON.stringify({
2362
- workers: (state.agentWorkers || []).map((w) => [w.tag, w.status, w.stage, w.sessionId]).slice(0, 20),
2363
- jobs: (state.agentJobs || []).map((j) => [j.task_id, j.status, j.tag, j.sessionId, j.startedAt, j.finishedAt, j.error]).slice(0, 20),
2364
- }), [state.agentWorkers, state.agentJobs]);
1042
+ // Agent revision + active-tool signature + statusline stats:
1043
+ // app/use-transcript-activity.mjs.
1044
+ const { agentRevision, activeToolsSignature, activeTools, statuslineStats } = useTranscriptActivity({ state });
2365
1045
 
2366
- // L2 statusline explore/search segments are the MAIN session's own running
2367
- // tool cards (NOT agentWorkers/agentJobs). Derive pending counts + oldest
2368
- // start time straight from the live transcript tool cards. A card is pending
2369
- // until completedCount >= count. (Do NOT use completedAt as the terminal
2370
- // signal: engine patchToolCardResult stamps completedAt on EVERY aggregate
2371
- // result patch even while calls are still running, and reused tail-aggregates
2372
- // keep a stale completedAt, so it would drop the segment early / skip newly
2373
- // added pending calls.) Aggregate cards carry a `categories` map; standalone
2374
- // cards carry name/args resolved
2375
- // via classifyToolCategory. Keep this CHEAP: build a primitive signature from
2376
- // only the tool items so streaming flushes that swap
2377
- // state.items for a fresh array don't restringify the whole transcript and
2378
- // the StatusLine effect only re-fires when the numbers actually change.
2379
- const activeToolsSignature = useMemo(() => {
2380
- // The engine maintains this signature incrementally (updated on tool
2381
- // start/early-complete/result/turn-end), so App no longer scans every
2382
- // transcript item on each change. Prefer it; fall back to the local scan
2383
- // only when the engine did not publish it (older snapshot).
2384
- if (state.activeToolSummary !== undefined) return state.activeToolSummary || '';
2385
- const items = state.items || [];
2386
- let exploreCount = 0;
2387
- let exploreStart = 0;
2388
- let searchCount = 0;
2389
- let searchStart = 0;
2390
- for (const it of items) {
2391
- if (!it || it.kind !== 'tool') continue;
2392
- const count = Math.max(1, Number(it.count || 1));
2393
- // Resolved check: aggregates stay on the pure completedCount>=count test
2394
- // because engine patchToolCardResult sets `result` on EVERY aggregate
2395
- // patch (even partial, completedCount<count), so a `result`-aware check
2396
- // would drop a still-running aggregate early. Standalone cards mirror
2397
- // toolItemPendingForRows (done when completedCount>=count OR a result
2398
- // landed) so an abnormally-finished card (cancelled/errored) that sets a
2399
- // result without bumping completedCount cannot pin a phantom segment.
2400
- const done = it.aggregate
2401
- ? Number(it.completedCount || 0)
2402
- : Math.max(0, Math.min(count, Number(it.completedCount || (it.result == null ? 0 : count))));
2403
- if (done >= count) continue; // resolved card (matches toolItemPendingForRows)
2404
- const started = Number(it.startedAt || 0);
2405
- let exploreHits = 0;
2406
- let searchHits = 0;
2407
- if (it.aggregate && it.categories && typeof it.categories === 'object') {
2408
- for (const v of Object.values(it.categories)) {
2409
- const cat = v && typeof v === 'object' ? v.category : null;
2410
- const c = Math.max(1, Number(v && typeof v === 'object' ? v.count : 1) || 1);
2411
- if (cat === 'Explore') exploreHits += c;
2412
- else if (cat === 'Web Research') searchHits += c;
2413
- }
2414
- } else if (it.name) {
2415
- const cat = classifyToolCategory(it.name, it.args || {});
2416
- if (cat === 'Explore') exploreHits = count;
2417
- else if (cat === 'Web Research') searchHits = count;
2418
- }
2419
- if (exploreHits > 0) {
2420
- exploreCount += exploreHits;
2421
- if (started > 0 && (exploreStart === 0 || started < exploreStart)) exploreStart = started;
2422
- }
2423
- if (searchHits > 0) {
2424
- searchCount += searchHits;
2425
- if (started > 0 && (searchStart === 0 || started < searchStart)) searchStart = started;
2426
- }
2427
- }
2428
- if (!exploreCount && !searchCount) return '';
2429
- return `${exploreCount}:${exploreStart}:${searchCount}:${searchStart}`;
2430
- }, [state.activeToolSummary, state.items]);
2431
-
2432
- const activeTools = useMemo(() => {
2433
- if (!activeToolsSignature) return null;
2434
- const [ec, es, sc, ss] = activeToolsSignature.split(':').map((n) => Number(n) || 0);
2435
- return {
2436
- explore: { count: ec, startedAt: es },
2437
- search: { count: sc, startedAt: ss },
2438
- };
2439
- }, [activeToolsSignature]);
2440
-
2441
- // StatusLine only reads a small stats subset; engine clones the full stats
2442
- // object on many updates. Memoize by field value so identical usage keeps
2443
- // the same object reference and React.memo / effect deps stay quiet.
2444
- const statuslineStats = useMemo(() => {
2445
- const s = state.stats || {};
2446
- return {
2447
- currentContextSource: s.currentContextSource ?? null,
2448
- currentEstimatedContextTokens: s.currentEstimatedContextTokens ?? 0,
2449
- currentContextTokens: s.currentContextTokens ?? 0,
2450
- contextTokens: s.contextTokens ?? 0,
2451
- latestPromptTokens: s.latestPromptTokens ?? 0,
2452
- latestInputTokens: s.latestInputTokens ?? 0,
2453
- latestCachedTokens: s.latestCachedTokens ?? 0,
2454
- latestCacheWriteTokens: s.latestCacheWriteTokens ?? 0,
2455
- inputTokens: s.inputTokens ?? 0,
2456
- cachedTokens: s.cachedTokens ?? 0,
2457
- cacheWriteTokens: s.cacheWriteTokens ?? 0,
2458
- promptTokens: s.promptTokens ?? 0,
2459
- turns: s.turns ?? 0,
2460
- };
2461
- }, [
2462
- state.stats?.currentContextSource,
2463
- state.stats?.currentEstimatedContextTokens,
2464
- state.stats?.currentContextTokens,
2465
- state.stats?.contextTokens,
2466
- state.stats?.latestPromptTokens,
2467
- state.stats?.latestInputTokens,
2468
- state.stats?.latestCachedTokens,
2469
- state.stats?.latestCacheWriteTokens,
2470
- state.stats?.inputTokens,
2471
- state.stats?.cachedTokens,
2472
- state.stats?.cacheWriteTokens,
2473
- state.stats?.promptTokens,
2474
- state.stats?.turns,
2475
- ]);
2476
-
2477
- // ── Transcript viewport height ──────────────────────────────────────────
2478
- // ROOT-CAUSE FIX: the transcript must live in a box with an EXPLICIT numeric
2479
- // height + overflow:hidden so ink's renderer actually clips off-screen rows
2480
- // (render-node-to-output.js → output.clip uses the box's computed height). An
2481
- // unbounded negative-margin column inside a flexGrow box let stale rows
2482
- // overprint newer ones across incremental redraws. We reserve the rows the
2483
- // bottom cluster needs and give the transcript everything above it.
2484
- //
2485
- // viewportHeight = rows
2486
- // − welcome header (empty transcript only)
2487
- // − live status (thinking / spinner / TurnDone)
2488
- // − queued prompts (marginTop 1 + N rows, only when queued)
2489
- // − prompt meta (spinner / transient message / queued)
2490
- // − input box (2 border + wrapped content)
2491
- // − statusline (reserved L1 + L2 + outer gap; total 3 rows)
2492
- //
2493
- // Every sibling outside the viewport must be accounted for here; otherwise
2494
- // the total tree height exceeds the terminal and the input box gets pushed.
2495
- const textEntryPrompt = providerPrompt || channelPrompt || hookPrompt || settingsPrompt;
2496
- const hasTextEntryPrompt = !!textEntryPrompt;
2497
- const hasFloatingPanel = !!(toolApproval || picker || contextPanel || usagePanel || slashPaletteOpen || hasTextEntryPrompt);
2498
- const expandedOptionPanel = !!(toolApproval || picker || contextPanel || usagePanel || hasTextEntryPrompt);
2499
- const panelTransitionForBoot = panelTransitionRef.current;
2500
- if (panelTransitionForBoot.signature.includes('picker:project') && !picker) {
2501
- projectBootInputLatchRef.current = true;
2502
- }
2503
- const bootSettling = !tuiReady && state.items.length === 0 && !hasFloatingPanel && !projectBootInputLatchRef.current;
2504
- // Project selection (initial-entry experience) keeps the welcome banner
2505
- // visible above the picker / path-entry prompt, unlike other floating panels.
2506
- const projectSelectionActive = picker?.kind === 'project'
2507
- || settingsPrompt?.kind === 'project-new'
2508
- || settingsPrompt?.kind === 'project-create-confirm'
2509
- || settingsPrompt?.kind === 'project-rename';
2510
- // Slash search floats above the normal prompt. Actual option panels own the
2511
- // prompt/status area, so they hide those rows and expand into that space.
2512
- const inputBoxHidden = expandedOptionPanel || bootSettling;
2513
- const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive || onboardingActive;
2514
- const WELCOME_ROWS = showWelcomeBanner ? 11 : 0;
2515
- const liveSpinner = state.spinner?.active ? state.spinner : (state.commandStatus?.active ? state.commandStatus : null);
2516
- // Command-status spinner (auto-clear/compact/etc.) is NOT part of the
2517
- // spinner → TurnDone handoff: it typically starts while the transcript tail
2518
- // is already a done row (idle session), so the done-at-tail suppression
2519
- // below must never hide it — that read as a frozen UI during auto-clear.
2520
- const liveSpinnerIsCommand = !state.spinner?.active && !!state.commandStatus?.active;
2521
- const latestToast = state.toasts?.length ? state.toasts[state.toasts.length - 1] : null;
2522
- const toastHint = latestToast ? latestToast.text : '';
2523
- const progressHint = state.progressHint || null;
2524
- const inputHint = promptHint || toastHint || (progressHint?.text || '');
2525
- const inputHintTone = promptHint
2526
- ? promptHintTone
2527
- : (latestToast?.tone || progressHint?.tone || 'info');
2528
- const latestTranscriptItem = state.items[state.items.length - 1] || null;
2529
- // Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
2530
- // (turndone/statusdone) is a normal transcript item and flows into scrollback
2531
- // like anything else, so the area directly above the prompt is CLEAR when the
2532
- // user is idle. Earlier this row was pinned in the meta band until the next
2533
- // transcript item was appended (to dodge an autowrap overprint/bleed), which
2534
- // left the completed status row stuck above the prompt while the user typed or
2535
- // sat idle. That bleed is now fixed at the source by the tool-output width
2536
- // clamp, so the pin is no longer needed. Kept as a named null const so the
2537
- // downstream meta-band/hint logic collapses cleanly to the spinner-only path.
2538
- const latestDoneItem = null;
2539
- const SCROLL_HINT_ROWS = 0;
2540
- const LIVE_STATUS_ROWS = 0;
2541
- // The standalone prompt box is 2 border rows + the wrapped PromptInput body.
2542
- // The one-row scroll baseline gap ABOVE the prompt is owned by
2543
- // transcriptGuardRows, not the prompt box itself. That keeps the scroll
2544
- // reference at "textbox + 1" while the prompt/statusline bottom stays fixed.
2545
- //
2546
- // This must track the prompt draft's REAL wrapped height. Reserving a constant
2547
- // one-line prompt lets long/multiline input grow the bottom cluster after the
2548
- // transcript viewport has already claimed those rows, which makes transcript
2549
- // body text overprint the textbox or slash command window.
2550
- const currentPromptLayoutRows = promptContentRows(promptLayoutValueRef.current, promptContentColumns);
2551
- const promptInputRows = inputBoxHidden ? 0 : currentPromptLayoutRows;
2552
- const promptBoxRows = inputBoxHidden ? 0 : 2 + promptInputRows;
2553
- const STATUSLINE_ROWS = 3;
2554
- // Shared panel chrome math. Every floating panel follows the same vertical
2555
- // rhythm INSIDE its round border: title row, blank, description/hint row,
2556
- // blank, then content. That is 4 non-content rows; the round border adds 2
2557
- // more, so chrome reserves 6 rows total. Reserving the full chrome here (even
2558
- // for panels that omit the description) guarantees the bordered title can
2559
- // never be clipped off the top — content rows shrink first when the terminal
2560
- // is short, because the floating container clips from the top (flex-end).
2561
- const PANEL_MAX_VISIBLE = 8;
2562
- const PANEL_CHROME_ROWS = 6;
2563
- const PANEL_BASE_ROWS = PANEL_MAX_VISIBLE + PANEL_CHROME_ROWS;
2564
- const PICKER_CHROME_ROWS = PANEL_CHROME_ROWS;
2565
- // TextEntryPanel content is one prompt line (chrome + 1) for single-line
2566
- // prompts, or up to PANEL_MAX_VISIBLE wrapped rows for core memory add/edit.
2567
- // PLUS an optional wrapped detail block (blank spacer + N wrapped rows — e.g.
2568
- // the manual OAuth URL). The floating container clips from the TOP (flex-end +
2569
- // overflow hidden), so under-reserving here pushed the bordered title off
2570
- // the top of the panel. Width matches the panel interior: frame − 2 border
2571
- // − 2 paddingX, same wrap-ansi math ink uses for wrap="wrap".
2572
- const textEntryKind = String(textEntryPrompt?.kind || '');
2573
- const textEntryMultiline = CORE_MULTILINE_TEXT_ENTRY_KINDS.has(textEntryKind);
2574
- const textEntryDetailText = String(textEntryPrompt?.detail || '').trim();
2575
- const textEntryDetailRows = textEntryDetailText
2576
- ? 1 + wrappedDetailRows(textEntryDetailText, Math.max(1, frameColumns - 4))
2577
- : 0;
2578
- const textEntryContentRows = textEntryMultiline ? textEntryLayoutRows : 1;
2579
- const TEXT_ENTRY_ROWS = PANEL_CHROME_ROWS + textEntryContentRows + textEntryDetailRows;
2580
- const OPTION_PANEL_EXTRA_ROWS = expandedOptionPanel ? 3 : 0;
2581
- const queuedVisible = !hasFloatingPanel && !inputBoxHidden && state.queued?.length > 0;
2582
- // While the slash palette is open it owns the area above the prompt, so the
2583
- // live spinner/meta row is suppressed entirely — no reservation and no render.
2584
- // Normalize the spinner → TurnDone handoff by making them occupy the SAME
2585
- // two-row slot. Completion appends turndone and clears spinner in one commit,
2586
- // so a completed row replaces the spinner slot with no transient jump. A
2587
- // statusdone can be emitted mid-turn (for example after compaction), so it
2588
- // must not suppress the still-active spinner.
2589
- const promptMetaVisible = isLiveSpinnerMetaVisible({
2590
- inputBoxHidden,
1046
+ // Transcript viewport + bottom-cluster row budget: app/shell-layout.mjs.
1047
+ const layout = computeShellLayout({
1048
+ providerPrompt,
1049
+ channelPrompt,
1050
+ hookPrompt,
1051
+ settingsPrompt,
1052
+ panelTransitionEpoch,
1053
+ panelInkMaskEpoch,
1054
+ toolApproval,
1055
+ picker,
1056
+ contextPanel,
1057
+ usagePanel,
2591
1058
  slashPaletteOpen,
2592
- liveSpinner,
2593
- liveSpinnerIsCommand,
2594
- latestTranscriptItem,
1059
+ tuiReady,
1060
+ state,
1061
+ resizeState,
1062
+ frameColumns,
1063
+ promptHint,
1064
+ promptHintTone,
1065
+ textEntryLayoutRows,
1066
+ onboardingActive,
1067
+ conditionalWelcomePromptHint,
1068
+ welcomePromptHintDismissed,
1069
+ welcomePromptHintRef,
1070
+ welcomePromptHintVisibleRef,
1071
+ panelTransitionRef,
1072
+ projectBootInputLatchRef,
1073
+ promptLayoutValueRef,
1074
+ promptContentColumns,
1075
+ transcriptBottomSlackRowsRef,
1076
+ transcriptViewportRef,
1077
+ frameRowsRef,
1078
+ promptBoxRectRef,
1079
+ panelCloseInkMaskRowsRef,
1080
+ CORE_MULTILINE_TEXT_ENTRY_KINDS,
1081
+ panelSignatureFlags,
1082
+ panelKindSignature,
1083
+ isInstantPanelCloseTransition,
1084
+ PANEL_LAYOUT_SIG,
2595
1085
  });
2596
- const promptMetaRows = promptMetaVisible ? 2 : 0;
2597
- // Toast/error text without a live spinner uses the existing transcript guard
2598
- // row directly above the prompt. Do NOT reserve another row here: that made a
2599
- // transient hint add a visible newline/prompt jump whenever no spinner was
2600
- // active.
2601
- const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
2602
- const overlayHintRows = 0;
2603
- // QueuedCommands renders each queued command at its FULL wrapped height
2604
- // (same content width the promoted transcript user row wraps at), pinned
2605
- // above the prompt box with no extra top-margin row. Reserving the true
2606
- // height keeps promotion from re-expanding the text mid-flight ("row jump").
2607
- // If the whole queue would eat too much of the frame, fall back to the old
2608
- // compact 1-row-per-entry truncation so the input box never leaves screen.
2609
- const queuedFullRows = queuedVisible
2610
- ? state.queued.reduce(
2611
- (sum, item) => sum + queuedBandRows(String(item.displayText || item.text || ''), Math.max(1, frameColumns - 4)),
2612
- 0,
2613
- )
2614
- : 0;
2615
- const queuedRowBudget = Math.max(3, Math.floor(resizeState.rows / 3));
2616
- const queuedCompact = queuedFullRows > queuedRowBudget;
2617
- const queuedRows = queuedVisible ? (queuedCompact ? state.queued.length : queuedFullRows) : 0;
2618
- const INPUT_BOX_ROWS = promptBoxRows + promptMetaRows + overlayHintRows;
2619
- const baseReserve = WELCOME_ROWS + SCROLL_HINT_ROWS + LIVE_STATUS_ROWS + INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows;
2620
- const maxFloatingPanelRows = Math.max(0, resizeState.rows - baseReserve - 1);
2621
- const desiredFloatingPanelRows = toolApproval
2622
- ? PANEL_CHROME_ROWS + 2 + OPTION_PANEL_EXTRA_ROWS
2623
- : picker
2624
- ? (picker.fillAvailable ? maxFloatingPanelRows : PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS)
2625
- : contextPanel
2626
- ? PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS + 3
2627
- : usagePanel
2628
- ? PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS
2629
- : slashPaletteOpen
2630
- ? PANEL_MAX_VISIBLE + PANEL_CHROME_ROWS
2631
- : hasTextEntryPrompt
2632
- ? TEXT_ENTRY_ROWS
2633
- : 0;
2634
- const floatingPanelRows = desiredFloatingPanelRows > 0
2635
- ? Math.min(desiredFloatingPanelRows, maxFloatingPanelRows)
2636
- : 0;
2637
- // Give the list every content row the panel exposes. The panel already grew
2638
- // by OPTION_PANEL_EXTRA_ROWS; previously that growth was subtracted back out
2639
- // here, so the rows leaked into an empty flexGrow gap instead of the list.
2640
- // Reserving only PICKER_CHROME_ROWS lets the list occupy the full interior
2641
- // (the footer's own reservation is handled inside Picker).
2642
- const pickerVisibleRows = picker
2643
- ? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS)
2644
- : PANEL_MAX_VISIBLE;
2645
- const rawBottomReserve = baseReserve + floatingPanelRows;
2646
- const bottomClusterRows = INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows + floatingPanelRows;
2647
- const panelLayoutSignature = [
2648
- toolApproval ? 'tool' : '',
2649
- picker ? `picker:${picker.kind || ''}:${picker.fillAvailable ? 'fill' : 'fit'}` : '',
2650
- contextPanel ? 'context' : '',
2651
- usagePanel ? 'usage' : '',
2652
- slashPaletteOpen ? 'slash' : '',
2653
- hasTextEntryPrompt ? `text:${textEntryPrompt?.kind || ''}` : '',
2654
- inputBoxHidden ? 'input-hidden' : 'input-visible',
2655
- floatingPanelRows,
1086
+ const {
1087
+ inputBoxHidden,
1088
+ latestTranscriptItem,
2656
1089
  promptBoxRows,
1090
+ STATUSLINE_ROWS,
2657
1091
  promptMetaRows,
1092
+ overlayHintRequested,
2658
1093
  queuedRows,
2659
1094
  WELCOME_ROWS,
2660
- ].join('|');
2661
- const panelTransition = panelTransitionRef.current;
2662
- const panelLayoutChanged = Boolean(panelTransition.signature && panelTransition.signature !== panelLayoutSignature);
2663
- let panelTransitionClearRows = 0;
2664
- let panelTransitionGuardRows = 0;
2665
- if (panelLayoutChanged) {
2666
- const panelShrinkRows = Math.max(0, panelTransition.reserve - bottomClusterRows);
2667
- const initialProjectEntryClose = state.items.length === 0;
2668
- // Prompt-row-only churn (promptBoxRows/promptMetaRows/queuedRows shifting
2669
- // while no panel opened/closed/changed kind and floatingPanelRows itself is
2670
- // unchanged — see PANEL_KIND_TOKEN_COUNT) must not fall into the clearRows
2671
- // + setTimeout recommit path below: that inserts a full extra blank row for
2672
- // one commit, which IS the newline-add/remove jolt while typing. Route a
2673
- // shrink here through the same one-commit ink-mask path as an instant panel
2674
- // close so the stale row is masked in the very commit it disappears; growth
2675
- // already needs no clearance (panelShrinkRows is 0 in that case).
2676
- const promptRowsOnlyChange = panelShrinkRows > 0
2677
- && panelKindSignature(panelTransition.signature) === panelKindSignature(panelLayoutSignature);
2678
- // Turn-end spinner meta collapse: the 2-row live-spinner band disappears in
2679
- // the SAME commit the engine appends the turndone/statusdone tail (see
2680
- // engine.mjs runTurn — turndone + spinner:null land in one set()). That new
2681
- // done row already replaces the vacated height, so masking those rows blank
2682
- // for one commit only to clear them on the next commit IS the visible
2683
- // transcript bounce. Exempt exactly the meta-collapse rows from the ink mask
2684
- // when the done row is the transcript tail. Every other prompt-row-only
2685
- // shrink (typing newline removal, queued-row churn) AND the reclaimed/no-op
2686
- // path (engine.mjs skips turndone, so the completed-tail check stays false and no
2687
- // row replaces the height) keep the mask so they still reclaim smoothly.
2688
- const prevMetaRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
2689
- const nextMetaRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
2690
- // Require the done row to have been appended in THIS commit (tail id changed
2691
- // since the last commit). A command spinner can leave a STALE done row at the
2692
- // tail and then clear without appending statusdone (e.g. /recall — see
2693
- // engine.mjs), collapsing the meta band with NO same-commit backfill; masking
2694
- // must stay on for that path or the vacated rows overpaint the stale row.
2695
- const doneTailAppendedThisCommit = isCompletedTranscriptTailAppendedThisCommit(
2696
- latestTranscriptItem,
2697
- panelTransition.tailId,
2698
- );
2699
- const spinnerMetaCollapseRows = doneTailAppendedThisCommit
2700
- ? Math.max(0, prevMetaRows - nextMetaRows)
2701
- : 0;
2702
- // Queued-band promotion: drain() removes the queued band and appends the
2703
- // promoted user transcript row in the SAME commit (session-flow.mjs drain
2704
- // → pushUserOrSyntheticItem → runTurn spinner, one microtask flush). The
2705
- // new user row (full wrapped height + margin) already backfills the
2706
- // vacated band rows, so masking them blank for one commit only to drop
2707
- // the mask on the next commit made the whole transcript bounce down.
2708
- // Exempt exactly the vacated queued rows when a user row landed at the
2709
- // tail in this commit; queue edits/removals without a tail append (tail
2710
- // id unchanged, or non-user tail) keep the mask.
2711
- const prevQueuedSigRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.QUEUED]) || 0;
2712
- const nextQueuedSigRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.QUEUED]) || 0;
2713
- const userTailAppendedThisCommit = latestTranscriptItem?.kind === 'user'
2714
- && (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
2715
- const queuedPromoteCollapseRows = userTailAppendedThisCommit
2716
- ? Math.max(0, prevQueuedSigRows - nextQueuedSigRows)
2717
- : 0;
2718
- const instantPanelClose = panelShrinkRows > 0
2719
- && (promptRowsOnlyChange
2720
- || isInstantPanelCloseTransition(panelTransition.signature, panelLayoutSignature, initialProjectEntryClose));
2721
- // Slash palette opening on the empty welcome screen: bottomReserve already
2722
- // grows to its final size in this same commit (floatingPanelRows reflects
2723
- // slashPaletteOpen immediately, no clearRows needed), but the renderer can
2724
- // still overpaint the just-vacated transcript row for one commit. Borrow
2725
- // the transitional guard-row mechanism (normally used for tall panel
2726
- // closes below) for exactly one commit on the open transition itself —
2727
- // this only carves an extra blank row out of transcriptContentHeight, it
2728
- // does not touch bottomReserve/floatingPanelRows/palette height.
2729
- const slashOpenOnEmptyTranscript = initialProjectEntryClose
2730
- && !panelSignatureFlags(panelTransition.signature).slash
2731
- && panelSignatureFlags(panelLayoutSignature).slash;
2732
- if (instantPanelClose) {
2733
- // Slash palette and initial project-entry closes land on the final bottom
2734
- // reserve in one commit. Paint reclaimed rows as a blank mask band below
2735
- // the transcript clip instead of inflating bottomReserve + reclaiming on
2736
- // the next tick. Subtract any turn-end spinner-meta rows that the same-
2737
- // commit done tail already backfills (spinnerMetaCollapseRows) so that
2738
- // transition masks nothing and does not bounce; same for queued-band
2739
- // rows backfilled by a just-promoted user row (queuedPromoteCollapseRows).
2740
- panelCloseInkMaskRowsRef.current = Math.max(0, panelShrinkRows - spinnerMetaCollapseRows - queuedPromoteCollapseRows);
2741
- panelTransition.clearRows = 0;
2742
- panelTransition.guardRows = 0;
2743
- panelTransition.epoch = panelTransitionEpoch;
2744
- } else if (slashOpenOnEmptyTranscript) {
2745
- panelTransitionClearRows = 0;
2746
- panelTransitionGuardRows = 1;
2747
- panelTransition.clearRows = 0;
2748
- panelTransition.guardRows = 1;
2749
- panelTransition.epoch = panelTransitionEpoch;
2750
- } else {
2751
- // Tall panel closes must land on the final bottom reserve in the same
2752
- // commit. Inflating bottomReserve with temporary clearance makes the
2753
- // transcript/prompt area move once, then snap back on the timer commit.
2754
- // Instead, keep the reclaimed rows inside the fixed viewport as a blank
2755
- // one-frame mask, matching the instant-close path above.
2756
- panelCloseInkMaskRowsRef.current = panelShrinkRows;
2757
- panelTransition.clearRows = 0;
2758
- panelTransition.guardRows = 0;
2759
- panelTransition.epoch = panelTransitionEpoch;
2760
- }
2761
- } else if (panelTransition.epoch === panelTransitionEpoch) {
2762
- panelTransitionClearRows = panelTransition.clearRows || 0;
2763
- panelTransitionGuardRows = panelTransition.guardRows || 0;
2764
- }
2765
- if (desiredFloatingPanelRows > 0) {
2766
- panelCloseInkMaskRowsRef.current = 0;
2767
- }
2768
- void panelInkMaskEpoch;
2769
- const panelCloseInkMaskRows = desiredFloatingPanelRows > 0 ? 0 : panelCloseInkMaskRowsRef.current;
2770
- const bottomReserve = rawBottomReserve + panelTransitionClearRows;
2771
- const viewportHeight = Math.max(1, resizeState.rows - bottomReserve);
2772
- // Keep one physical row between the transcript clip and the bottom cluster
2773
- // even when pinned to the live tail. Windows Terminal/conhost can still
2774
- // surface one clipped/off-by-one transcript row below the statusline during
2775
- // rapid tool-card updates; a permanent guard row makes that row blank instead
2776
- // of a tool header/detail.
2777
- const guardCapacityRows = Math.max(0, viewportHeight - 1);
2778
- const baseGuardRows = guardCapacityRows > 0 ? 1 : 0;
2779
- // ── Scroll-time overprint guard ───────────────────────────────────────────
2780
- // Wheel/manual scroll pushes the transcript column DOWN via a negative
2781
- // marginBottom (see the viewport render). Under conhost/Windows Terminal the
2782
- // incremental redraw can leave the row that slid past the clip edge painted
2783
- // OVER the bottom cluster (input box / statusline) for a frame — the reported
2784
- // "scrolled text shows on the statusline row" bug. One guard row is enough
2785
- // while pinned to the live tail, but during an active scroll the slid row can
2786
- // still bleed one line further, so widen the guard to TWO rows whenever the
2787
- // viewport is genuinely scrolled up. The extra blank row absorbs the stray
2788
- // paint instead of the statusline. Requires a viewport tall enough to spare
2789
- // the row, and never shrinks below the base guard.
2790
- // Gate on the same follow-aware basis the transcript window uses for
2791
- // renderScrollOffset (see use-transcript-window.mjs targetNearBottom): the
2792
- // live `scrollOffset` state can still read >0 for one frame after a wheel
2793
- // turn re-arms bottom-follow, while this same frame already renders with
2794
- // renderScrollOffset=0. Without this, the stale offset shrinks
2795
- // transcriptContentHeight by one row that never actually gets rendered,
2796
- // producing a one-row bounce that snaps back once state catches up.
2797
- // [2026-07-06] Scroll-time extra guard DISABLED: the widened (2-row) guard
2798
- // rendered as a visibly empty band between the transcript and the prompt box
2799
- // whenever the viewport was scrolled up (user-reported "bottom rows look
2800
- // blank while scrolling"). The base 1-row guard below stays; if the
2801
- // scrolled-row-over-statusline overpaint resurfaces, fix it in the renderer
2802
- // diff (clip/erase) instead of carving more blank viewport rows.
2803
- const scrollGuardRows = 0;
2804
- const transcriptGuardRows = Math.min(guardCapacityRows, baseGuardRows + panelTransitionGuardRows + scrollGuardRows);
2805
- // Welcome prompt hint: a one-row band rendered INSIDE the transcript
2806
- // viewport (as a sibling below the content clip), so it must be part of the
2807
- // viewport row accounting computed right below. Left unaccounted, the slash
2808
- // palette close commit on the empty welcome screen painted one extra
2809
- // physical row (hint reappears in the same commit as the close mask), so
2810
- // the prompt box + statusline dipped one row and snapped back on the
2811
- // mask-clear commit.
2812
- const welcomePromptHintText = conditionalWelcomePromptHint || welcomePromptHintRef.current || '';
2813
- const welcomePromptHintVisible = Boolean(
2814
- welcomePromptHintText
2815
- && !welcomePromptHintDismissed
2816
- && state.items.length === 0
2817
- && !hasFloatingPanel
2818
- && !inputBoxHidden
2819
- && !queuedVisible
2820
- && !liveSpinner
2821
- && !inputHint
2822
- );
2823
- // Tiny terminals: guard rows can already consume all but one viewport row
2824
- // (guardCapacityRows = viewportHeight - 1). transcriptContentHeight clamps
2825
- // to >= 1, so an unconditional hint row would paint viewportHeight + 1 rows
2826
- // and push the prompt/statusline down. The hint yields unless at least one
2827
- // content row remains beside it.
2828
- const welcomePromptHintRows = welcomePromptHintVisible
2829
- && (viewportHeight - transcriptGuardRows) >= 2 ? 1 : 0;
2830
- welcomePromptHintVisibleRef.current = welcomePromptHintRows > 0;
2831
- // Transient hint/error on the EMPTY transcript: the guard row sits directly
2832
- // above the prompt box, so painting the hint there hugs the textbox one row
2833
- // below where the live-spinner line renders. Carve one in-viewport row ABOVE
2834
- // the guard row instead so the hint's baseline matches the spinner row (two
2835
- // rows above the box, guard row stays blank as the spacer). This is an
2836
- // in-viewport carve like welcomePromptHintRows — bottomReserve is untouched,
2837
- // so the prompt box and statusline never move. Non-empty transcripts keep the
2838
- // existing attach-to-last-item / guard-row fallback placements.
2839
- const overlayHintBandRows = overlayHintRequested
2840
- && state.items.length === 0
2841
- && (viewportHeight - transcriptGuardRows - welcomePromptHintRows) >= 2 ? 1 : 0;
2842
- // Instant panel close (slash palette): the reclaimed rows stay blank for
2843
- // exactly one commit via panelCloseMaskRows. The mask MUST be part of this
2844
- // frame's row accounting — subtract it from the transcript content height
2845
- // and render it as a sibling band below the content clip (where the closed
2846
- // panel's ink was). Rendering it inside the scrolled transcript column made
2847
- // the painted column taller than the accounted viewport for one frame, so
2848
- // the prompt/statusline dropped a row and snapped back on the mask-clear
2849
- // commit (the "textbox dips when the slash palette closes" bug).
2850
- const panelCloseMaskRows = Math.min(
2851
- panelCloseInkMaskRows,
2852
- Math.max(0, viewportHeight - transcriptGuardRows - welcomePromptHintRows - overlayHintBandRows - 1),
2853
- );
2854
- const transcriptContentHeight = Math.max(
2855
- 1,
2856
- viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows - overlayHintBandRows,
2857
- );
2858
- // Keep the keyboard-selection edge step anchored to the base guard. This is
2859
- // not a follow threshold: any positive wheel target is a reading position;
2860
- // only the true bottom may auto-follow a live tail.
2861
- const transcriptBottomSlackRows = Math.max(0, baseGuardRows);
2862
- transcriptBottomSlackRowsRef.current = transcriptBottomSlackRows;
2863
- transcriptViewportRef.current = {
2864
- top: WELCOME_ROWS,
2865
- bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS + transcriptContentHeight - 1),
2866
- };
2867
- // [mixdog] Keep the live terminal row count current for the mouse handler's
2868
- // region routing + status-band selection clip (see onData).
2869
- frameRowsRef.current = Math.max(1, Number(resizeState.rows) || 24);
2870
- // When the prompt box is hidden (floating panel / option panel owns the
2871
- // bottom area), drop its stale measured rect so the mouse handler does not
2872
- // route presses to a prompt box that is not on screen.
2873
- if (inputBoxHidden) promptBoxRectRef.current = null;
2874
- // Toast/error text has two mutually exclusive placements:
2875
- // - while a live status row exists (thinking/compacting/responding), attach it
2876
- // to that row so the bottom cluster reserves exactly one status band;
2877
- // - otherwise render it into the normal one-row gap above the prompt, replacing
2878
- // the blank spacer instead of reserving an extra row. This keeps late errors
2879
- // from pushing the prompt/statusline upward, and when thinking starts the
2880
- // hint moves into the live row on the same render instead of double-painting.
2881
- // Transient hint placement while no spinner owns the band is resolved after
2882
- // transcript windowing (see overlayHintOnLastItem / overlayHintFallbackRow).
2883
- const spinnerHintWidth = inputHint
2884
- ? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
2885
- : 0;
2886
- // When no live spinner owns a status band, the transient hint/error is drawn
2887
- // into the existing transcript guard row directly above the prompt. Mirror the
2888
- // spinner-row placement: a fixed-width right slot, not a full-width left box.
2889
- const guardHintWidth = inputHint
2890
- ? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
2891
- : 0;
2892
- const transientStatusWidth = liveSpinner ? spinnerHintWidth : guardHintWidth;
2893
- const promptSpinnerColumns = liveSpinner && inputHint
2894
- ? Math.max(1, frameColumns - spinnerHintWidth - 1)
2895
- : frameColumns;
1095
+ floatingPanelRows,
1096
+ bottomClusterRows,
1097
+ panelLayoutSignature,
1098
+ panelTransitionClearRows,
1099
+ panelTransitionGuardRows,
1100
+ transcriptGuardRows,
1101
+ transcriptContentHeight,
1102
+ transcriptBottomSlackRows,
1103
+ } = layout;
2896
1104
  useEffect(() => {
2897
1105
  const transition = panelTransitionRef.current;
2898
1106
  const pendingInkMask = panelCloseInkMaskRowsRef.current;
@@ -3008,484 +1216,69 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
3008
1216
  // The hardware/IME caret is parked by PromptInput from its OWN measured box
3009
1217
  // position (ink useCursor + useBoxMetrics) — correct now that the transcript
3010
1218
  // is a live column, so the live-frame line count ink relies on is accurate.
3011
- const promptInputControl = (
3012
- <PromptInput
3013
- onSubmit={onSubmit}
3014
- disabled={exiting || !!picker || !!toolApproval || !tuiReady}
3015
- onDraftChange={onPromptDraftChange}
3016
- interruptActive={state.busy}
3017
- onInterrupt={handlePromptInterrupt}
3018
- initialValue={promptDraft}
3019
- draftOverride={promptDraftOverride}
3020
- valueRef={promptValueRef}
3021
- selectionRef={promptSelectionRef}
3022
- boxRectRef={promptBoxRectRef}
3023
- mouseSelectionRef={promptMouseSelectionRef}
3024
- suppressShiftNavRef={gridSelectionActiveRef}
3025
- hint=""
3026
- hintTone={inputHintTone}
3027
- mask={false}
3028
- onEscape={handlePromptEscape}
3029
- onTab={cycleWorkflowFromPrompt}
3030
- onPasteText={handlePromptPaste}
3031
- onHistoryNavigate={handlePromptHistoryNavigate}
3032
- // Palette stays MOUNTED with 0 matches (stable height, no flicker), but
3033
- // key capture (Enter/arrows/Esc routing) only engages when a command can
3034
- // actually be accepted — otherwise Enter must submit the raw text as
3035
- // before instead of dead-ending in the palette accept path.
3036
- commandPaletteActive={slashPaletteOpen && slashCommands.length > 0}
3037
- onCommandPaletteNavigate={(direction) => {
3038
- setSlashIndex((index) => {
3039
- const total = slashCommands.length;
3040
- if (total === 0) return 0;
3041
- if (direction === 'home') return 0;
3042
- if (direction === 'end') return total - 1;
3043
- const step = direction === 'left'
3044
- ? -1
3045
- : direction === 'right'
3046
- ? 1
3047
- : Number(direction) || 0;
3048
- if (step === 1 || step === -1) return (index + step + total) % total;
3049
- return Math.max(0, Math.min(total - 1, index + step));
3050
- });
3051
- }}
3052
- onCommandPaletteAccept={acceptSlashPalette}
3053
- onCommandPaletteCancel={cancelSlashPalette}
3054
- onCommandPaletteComplete={completeSlashPalette}
3055
- onRestoreQueued={(currentText) => restoreQueuedToPrompt({ restoreDraft: true, showHint: false, currentText })}
3056
- />
3057
- );
3058
-
3059
- return (
3060
- // Fullscreen layout: a full-height column (height = terminal rows) pins the
3061
- // input cluster + statusline to the physical bottom (flexShrink={0}), while
3062
- // the transcript fills the space above and is bottom-aligned so messages
3063
- // stack up from just over the input. A top flexGrow spacer sinks the whole
3064
- // stack to the bottom; the transcript itself is a fixed-height clipping
3065
- // viewport (see viewportHeight above).
3066
- <Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={surfaceBackground()}>
3067
- {/* Empty-transcript header stays outside the bottom-anchored viewport and
3068
- has its own reserved rows, so it cannot steal space from the input. */}
3069
- {showWelcomeBanner ? (
3070
- <Box flexDirection="column" height={7} flexShrink={0} marginTop={3} marginBottom={1} backgroundColor={surfaceBackground()}>
3071
- <Text color={theme.text} bold>{centerLine('███╗ ███╗██╗██╗ ██╗██████╗ ██████╗ ██████╗ ', frameColumns)}</Text>
3072
- <Text color={theme.text} bold>{centerLine('████╗ ████║██║╚██╗██╔╝██╔══██╗██╔═══██╗██╔════╝ ', frameColumns)}</Text>
3073
- <Text color={theme.logo ?? theme.claude} bold>{centerLine('██╔████╔██║██║ ╚███╔╝ ██║ ██║██║ ██║██║ ███╗', frameColumns)}</Text>
3074
- <Text color={theme.logo ?? theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
3075
- <Text color={theme.logo ?? theme.claude} bold>{centerLine('██║ ╚═╝ ██║██║██╔╝ ██╗██████╔╝╚██████╔╝╚██████╔╝', frameColumns)}</Text>
3076
- <Box height={1} flexShrink={0} />
3077
- <Text color={theme.inactive}>{centerLine(`mixdog coding agent · v${localPackageVersion()} · ${state.cwd}`, frameColumns, 4)}</Text>
3078
- </Box>
3079
- ) : null}
3080
-
3081
- {/* Transcript viewport — a BOUNDED, fixed-height clipping box. The explicit
3082
- numeric height + overflow:hidden is what lets ink actually slice the
3083
- off-screen rows (output.clip in render-node-to-output.js), so older
3084
- rows can never overprint newer ones. justifyContent flex-end keeps the
3085
- newest content pinned to the bottom edge; older content overflows the
3086
- TOP and is clipped. flexShrink lets it yield rows to the live status /
3087
- a multi-line input rather than overflow the screen. */}
3088
- <Box
3089
- flexDirection="column"
3090
- width="100%"
3091
- height={viewportHeight}
3092
- flexGrow={0}
3093
- flexShrink={1}
3094
- overflow="hidden"
3095
- justifyContent="flex-end"
3096
- >
3097
- <Box
3098
- flexDirection="column"
3099
- width="100%"
3100
- height={transcriptContentHeight}
3101
- flexShrink={0}
3102
- overflow="hidden"
3103
- justifyContent="flex-end"
3104
- >
3105
- {/* Wheel scroll: with the viewport bottom-anchored (flex-end), a NEGATIVE
3106
- marginBottom pushes the transcript column DOWN past the bottom edge,
3107
- bringing older content above the window into view (overflow hidden
3108
- clips the newest rows that slide below). 0 = newest content pinned to
3109
- the bottom. (marginTop has no effect under flex-end — the bottom edge
3110
- stays fixed — so the scroll axis here is marginBottom, not marginTop.)
3111
- scrollOffset is clamped ≥ 0 by the wheel handler; a new turn snaps it
3112
- back to 0. */}
3113
- <Box flexDirection="column" width="100%" flexShrink={0} marginBottom={-transcriptWindow.effectiveScrollOffset}>
3114
- {/*
3115
- * Transcript windowing: render only the rows around the viewport rather
3116
- * than the full state.items list. A cheap bottom spacer preserves the
3117
- * same scroll coordinate when the visible window is in older history;
3118
- * items above the window are off-screen and omitted entirely.
3119
- * MAX cap: TRANSCRIPT_WINDOW_MAX_ITEMS items (env MIXDOG_TUI_TRANSCRIPT_WINDOW_ITEMS).
3120
- * OVERSCAN: TRANSCRIPT_WINDOW_OVERSCAN_ROWS extra rows above the viewport so
3121
- * fast wheel scrolls don't show a blank gap before re-render.
3122
- */}
3123
- {renderedTranscriptItems.map((item, i, arr) => {
3124
- const measureRef = transcriptMeasureRef(item);
3125
- const attachOverlayHint = overlayHintOnLastItem && i === overlayHintAttachItemIndex;
3126
- const itemNode = (
3127
- <Item
3128
- item={item}
3129
- prevKind={i > 0
3130
- ? arr[i - 1].kind
3131
- : (state.transcriptViewItems || state.items)[transcriptWindow.startIndex - 1]?.kind ?? null}
3132
- columns={frameColumns}
3133
- toolOutputExpanded={toolOutputExpanded}
3134
- rightMessage={attachOverlayHint ? inputHint : ''}
3135
- rightTone={attachOverlayHint ? inputHintTone : 'info'}
3136
- rightMessageWidth={attachOverlayHint ? (guardHintWidth || transientStatusWidth || 24) : 24}
3137
- themeEpoch={state.themeEpoch || 0}
3138
- streamingWindowRows={transcriptTailPinned && item.id === state.streamingTail?.id
3139
- ? transcriptContentHeight + TRANSCRIPT_WINDOW_TAIL_OVERSCAN_ROWS
3140
- : 0}
3141
- />
3142
- );
3143
- // When measured-rows is on, wrap each row in a zero-cost flex column
3144
- // whose ref exposes the row's REAL Yoga height to the harvest effect.
3145
- // The wrapper adds no rows of its own (it shrink-wraps the child) and
3146
- // is omitted entirely when the feature is disabled so the default
3147
- // render tree is byte-for-byte unchanged on the off path.
3148
- return measureRef ? (
3149
- <Box key={item.id} ref={measureRef} flexDirection="column" flexShrink={0}>
3150
- {itemNode}
3151
- </Box>
3152
- ) : (
3153
- <React.Fragment key={item.id}>{itemNode}</React.Fragment>
3154
- );
3155
- })}
3156
- {transcriptWindow.bottomSpacerRows > 0 ? (
3157
- <Box height={transcriptWindow.bottomSpacerRows} flexShrink={0} />
3158
- ) : null}
3159
- </Box>
3160
- </Box>
3161
- {welcomePromptHintRows > 0 ? (
3162
- <Box height={1} flexShrink={0} width="100%" overflow="hidden">
3163
- <Text color={theme.inactive} wrap="truncate">{centerLine(welcomePromptHintText, frameColumns, 2)}</Text>
3164
- </Box>
3165
- ) : null}
3166
- {panelCloseMaskRows > 0 ? (
3167
- <Box
3168
- height={panelCloseMaskRows}
3169
- flexShrink={0}
3170
- width="100%"
3171
- overflow="hidden"
3172
- backgroundColor={surfaceBackground()}
3173
- />
3174
- ) : null}
3175
- {overlayHintBandRows > 0 ? (
3176
- <Box height={1} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
3177
- <Box flexGrow={1} flexShrink={1} overflow="hidden" />
3178
- <Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
3179
- <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
3180
- </Box>
3181
- </Box>
3182
- ) : null}
3183
- {transcriptGuardRows > 0 ? (
3184
- <Box height={transcriptGuardRows} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
3185
- <Box flexGrow={1} flexShrink={1} overflow="hidden" />
3186
- {overlayHintFallbackRow && overlayHintBandRows === 0 ? (
3187
- <Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
3188
- <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
3189
- </Box>
3190
- ) : null}
3191
- </Box>
3192
- ) : null}
3193
- </Box>
3194
-
3195
- {/* Live reasoning and transient status live just above the prompt: reasoning
3196
- on the left, short-lived copy/error/info messages on the right. */}
3197
-
3198
- {/* Bottom bar — pinned to the physical bottom, never moves. Floating
3199
- panels use their actual rendered height and shrink before the prompt
3200
- can move; overflow is clipped from the top while the panel remains
3201
- bottom-aligned against the prompt. */}
3202
- <Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={surfaceBackground()}>
3203
- {panelTransitionClearRows > 0 ? (
3204
- <Box height={panelTransitionClearRows} flexShrink={0} width="100%" overflow="hidden" backgroundColor={surfaceBackground()} />
3205
- ) : null}
3206
- {floatingPanelRows > 0 ? (
3207
- <Box flexDirection="column" flexShrink={0} height={floatingPanelRows} overflow="hidden" justifyContent="flex-end" backgroundColor={surfaceBackground()}>
3208
- {toolApproval ? (
3209
- <Picker
3210
- items={[
3211
- { value: 'deny', label: 'Deny', marker: '×', markerColor: theme.error, description: 'block this tool call' },
3212
- { value: 'approve', label: 'Approve once', marker: '✓', markerColor: theme.success, description: 'run this tool call' },
3213
- ]}
3214
- onSelect={(value) => {
3215
- store.resolveToolApproval?.(toolApproval.id, {
3216
- approved: value === 'approve',
3217
- reason: value === 'approve' ? 'approved by user' : 'denied by user',
3218
- });
3219
- }}
3220
- onCancel={() => {
3221
- store.resolveToolApproval?.(toolApproval.id, { approved: false, reason: 'denied by user' });
3222
- }}
3223
- onKey={(input) => {
3224
- const value = String(input || '').trim().toLowerCase();
3225
- if (value === 'a' || value === 'y') {
3226
- store.resolveToolApproval?.(toolApproval.id, { approved: true, reason: 'approved by user' });
3227
- } else if (value === 'd' || value === 'n') {
3228
- store.resolveToolApproval?.(toolApproval.id, { approved: false, reason: 'denied by user' });
3229
- }
3230
- }}
3231
- title="Tool approval"
3232
- description={toolApprovalDescription(toolApproval)}
3233
- help="↑/↓ Select · Enter Choose · a/y Approve · d/n/Esc Deny"
3234
- columns={frameColumns}
3235
- labelWidth={18}
3236
- initialIndex={0}
3237
- indexMode="never"
3238
- visibleCount={2}
3239
- fillHeight={expandedOptionPanel}
3240
- />
3241
- ) : picker ? (
3242
- <Picker
3243
- key={picker.pickerKey}
3244
- items={picker.items}
3245
- onSelect={(value, item) => {
3246
- pickerOpenedFromEnterRef.current = true;
3247
- if (pickerOpenedFromEnterTimerRef.current) {
3248
- clearTimeout(pickerOpenedFromEnterTimerRef.current);
3249
- pickerOpenedFromEnterTimerRef.current = null;
3250
- }
3251
- try {
3252
- if (picker.onSelect) picker.onSelect(value, item);
3253
- } finally {
3254
- pickerOpenedFromEnterTimerRef.current = setTimeout(() => {
3255
- pickerOpenedFromEnterRef.current = false;
3256
- pickerOpenedFromEnterTimerRef.current = null;
3257
- }, 3000);
3258
- }
3259
- }}
3260
- onCancel={() => {
3261
- if (picker.onCancel) picker.onCancel();
3262
- else {
3263
- setPicker(null);
3264
- clearPromptHint();
3265
- }
3266
- }}
3267
- onLeft={picker.onLeft}
3268
- onRight={picker.onRight}
3269
- onTab={picker.onTab}
3270
- onKey={picker.onKey}
3271
- onHighlight={picker.onHighlight}
3272
- title={picker.title}
3273
- description={picker.description}
3274
- footer={picker.footer}
3275
- footerGapRows={picker.footerGapRows}
3276
- help={picker.help}
3277
- columns={frameColumns}
3278
- labelWidth={picker.labelWidth}
3279
- metaWidth={picker.metaWidth}
3280
- initialIndex={picker.initialIndex}
3281
- indexMode={picker.indexMode}
3282
- visibleCount={pickerVisibleRows}
3283
- fillHeight={expandedOptionPanel}
3284
- themeEpoch={state.themeEpoch || 0}
3285
- confirmBar={picker.confirmBar}
3286
- />
3287
- ) : contextPanel ? (
3288
- <ContextPanel
3289
- rows={contextPanel.rows}
3290
- title={contextPanel.title}
3291
- detail={contextPanel.detail}
3292
- columns={frameColumns}
3293
- fillHeight={expandedOptionPanel}
3294
- />
3295
- ) : usagePanel ? (
3296
- <UsagePanel
3297
- dashboard={usagePanel}
3298
- columns={frameColumns}
3299
- fillHeight={expandedOptionPanel}
3300
- panelRows={floatingPanelRows}
3301
- />
3302
- ) : slashPaletteOpen ? (
3303
- <SlashCommandPalette
3304
- commands={slashCommands}
3305
- selectedIndex={slashIndex}
3306
- title="Commands"
3307
- columns={frameColumns}
3308
- query={activeSlashQuery}
3309
- />
3310
- ) : providerPrompt ? (
3311
- <TextEntryPanel
3312
- title={providerPrompt.kind === 'api-key'
3313
- ? `${providerPrompt.mode === 'replace' ? 'Replace' : 'Set'} API key · ${providerPrompt.label}`
3314
- : providerPrompt.kind === 'oauth-code'
3315
- ? providerPrompt.label
3316
- : providerPrompt.kind === 'openai-usage-session'
3317
- ? 'OpenAI Usage · Session Key'
3318
- : `Base URL · ${providerPrompt.label}`}
3319
- hint={providerPrompt.kind === 'api-key'
3320
- ? [
3321
- providerPrompt.envName ? `Env: ${providerPrompt.envName}` : '',
3322
- providerPrompt.source ? `Current: ${providerPrompt.source}` : '',
3323
- 'Stored in the OS keychain.',
3324
- ].filter(Boolean).join(' · ')
3325
- : providerPrompt.kind === 'oauth-code'
3326
- ? (providerPrompt.hint || 'Paste the browser code.')
3327
- : providerPrompt.kind === 'openai-usage-session'
3328
- ? 'Paste an OpenAI dashboard/session key for the undocumented credit lookup. It is stored in the OS keychain.'
3329
- : `Default: ${providerPrompt.defaultURL}`}
3330
- detail={providerPrompt.detail || ''}
3331
- mask={providerPrompt.kind === 'api-key' || providerPrompt.kind === 'openai-usage-session'}
3332
- columns={frameColumns}
3333
- actionLabel={providerPrompt.kind === 'oauth-code' ? 'continue' : 'save'}
3334
- promptLabel={providerPrompt.kind === 'api-key'
3335
- ? 'API key > '
3336
- : providerPrompt.kind === 'oauth-code'
3337
- ? 'Paste code here if prompted > '
3338
- : providerPrompt.kind === 'openai-usage-session'
3339
- ? 'Session key > '
3340
- : 'Base URL > '}
3341
- onSubmit={onSubmit}
3342
- onCancel={cancelProviderPrompt}
3343
- />
3344
- ) : channelPrompt ? (
3345
- <TextEntryPanel
3346
- title={channelPrompt.label}
3347
- hint={channelPrompt.hint || 'Save channel setting.'}
3348
- mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'telegram-token' || channelPrompt.kind === 'webhook-token'}
3349
- columns={frameColumns}
3350
- promptLabel="Value > "
3351
- onSubmit={onSubmit}
3352
- onCancel={cancelChannelPrompt}
3353
- />
3354
- ) : hookPrompt ? (
3355
- <TextEntryPanel
3356
- title={hookPrompt.label}
3357
- hint={hookPrompt.hint || 'Save hook setting.'}
3358
- columns={frameColumns}
3359
- promptLabel="Value > "
3360
- onSubmit={onSubmit}
3361
- onCancel={cancelHookPrompt}
3362
- />
3363
- ) : settingsPrompt ? (
3364
- <TextEntryPanel
3365
- title={settingsPrompt.label}
3366
- hint={settingsPrompt.hint || 'Save setting.'}
3367
- columns={frameColumns}
3368
- initialValue={settingsPrompt.initialValue || ''}
3369
- multiline={settingsPrompt.kind === 'core-add' || settingsPrompt.kind === 'core-edit'}
3370
- maxContentRows={PANEL_MAX_VISIBLE}
3371
- onContentRowsChange={setTextEntryLayoutRows}
3372
- actionLabel={settingsPrompt.kind === 'skill-use'
3373
- ? 'run'
3374
- : settingsPrompt.kind === 'autoclear-provider'
3375
- ? 'save'
3376
- : settingsPrompt.kind === 'project-new'
3377
- ? 'open'
3378
- : settingsPrompt.kind === 'project-create-confirm'
3379
- ? 'confirm'
3380
- : settingsPrompt.kind === 'project-rename'
3381
- ? 'rename'
3382
- : settingsPrompt.kind === 'core-add'
3383
- ? 'add'
3384
- : settingsPrompt.kind === 'core-edit'
3385
- ? 'save'
3386
- : settingsPrompt.kind === 'core-delete-confirm'
3387
- ? 'confirm'
3388
- : 'save'}
3389
- promptLabel={settingsPrompt.kind === 'skill-use'
3390
- ? 'Command > '
3391
- : settingsPrompt.kind === 'autoclear-provider'
3392
- ? 'Duration > '
3393
- : settingsPrompt.kind === 'project-new'
3394
- ? 'Path > '
3395
- : settingsPrompt.kind === 'project-create-confirm'
3396
- ? 'Create? (y/n) > '
3397
- : settingsPrompt.kind === 'project-rename'
3398
- ? 'Name > '
3399
- : settingsPrompt.kind === 'core-add'
3400
- ? 'Sentence > '
3401
- : settingsPrompt.kind === 'core-edit'
3402
- ? 'Sentence > '
3403
- : settingsPrompt.kind === 'core-delete-confirm'
3404
- ? 'Delete? (y/n) > '
3405
- : 'Value > '}
3406
- onSubmit={onSubmit}
3407
- onCancel={cancelSettingsPrompt}
3408
- />
3409
- ) : null}
3410
- </Box>
3411
- ) : null}
3412
- {!inputBoxHidden ? (
3413
- <>
3414
- {promptMetaVisible ? (
3415
- <>
3416
- <Box
3417
- marginTop={0}
3418
- marginBottom={0}
3419
- height={1}
3420
- width="100%"
3421
- flexDirection="row"
3422
- backgroundColor={surfaceBackground()}
3423
- >
3424
- <Box flexGrow={1} flexShrink={1} overflow="hidden">
3425
- {liveSpinner ? (
3426
- <Spinner
3427
- verb={liveSpinner.verb}
3428
- startedAt={liveSpinner.startedAt}
3429
- outputTokens={liveSpinner?.outputTokens ?? liveSpinner?.tokens ?? 0}
3430
- thinking={!!(state.thinking || liveSpinner?.thinking)}
3431
- thinkingActiveSince={liveSpinner?.thinkingSegmentStartedAt ?? 0}
3432
- mode={liveSpinner?.mode || 'responding'}
3433
- columns={promptSpinnerColumns}
3434
- marginTop={0}
3435
- />
3436
- ) : null}
3437
- </Box>
3438
- {inputHint ? (
3439
- <Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
3440
- <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
3441
- </Box>
3442
- ) : null}
3443
- </Box>
3444
- <Box height={1} width="100%" backgroundColor={surfaceBackground()} />
3445
- </>
3446
- ) : null}
3447
- {queuedVisible ? (
3448
- <QueuedCommands queued={state.queued} columns={frameColumns} compact={queuedCompact} />
3449
- ) : null}
3450
- <Box
3451
- marginTop={0}
3452
- width="100%"
3453
- height={promptBoxRows}
3454
- flexShrink={0}
3455
- borderStyle="round"
3456
- borderColor={theme.promptBorder}
3457
- backgroundColor={surfaceBackground()}
3458
- paddingX={1}
3459
- >
3460
- {promptInputControl}
3461
- </Box>
3462
- </>
3463
- ) : null}
3464
- <StatusLine
3465
- sessionId={state.sessionId}
3466
- clientHostPid={state.ownerClientHostPid || state.clientHostPid}
3467
- provider={state.provider}
3468
- model={state.model}
3469
- effort={state.effort}
3470
- fast={state.fast}
3471
- cwd={state.cwd}
3472
- stats={statuslineStats}
3473
- contextWindow={state.contextWindow}
3474
- displayContextWindow={state.displayContextWindow}
3475
- compactBoundaryTokens={state.compactBoundaryTokens}
3476
- autoCompactTokenLimit={state.autoCompactTokenLimit}
3477
- rawContextWindow={state.rawContextWindow}
3478
- resizeEpoch={resizeEpoch}
3479
- agentRevision={agentRevision}
3480
- agentWorkers={state.agentWorkers}
3481
- agentJobs={state.agentJobs}
3482
- activeTools={activeTools}
3483
- initialLine={initialStatusLine}
3484
- workflow={state.workflow}
3485
- remoteEnabled={state.remoteEnabled === true}
3486
- themeEpoch={state.themeEpoch || 0}
3487
- />
3488
- </Box>
3489
- </Box>
3490
- );
1219
+ // Full view tree: app/app-view.jsx.
1220
+ return renderAppView({
1221
+ ...layout,
1222
+ acceptSlashPalette,
1223
+ activeSlashQuery,
1224
+ activeTools,
1225
+ agentRevision,
1226
+ cancelChannelPrompt,
1227
+ cancelHookPrompt,
1228
+ cancelProviderPrompt,
1229
+ cancelSettingsPrompt,
1230
+ cancelSlashPalette,
1231
+ channelPrompt,
1232
+ clearPromptHint,
1233
+ completeSlashPalette,
1234
+ contextPanel,
1235
+ cycleWorkflowFromPrompt,
1236
+ exiting,
1237
+ frameColumns,
1238
+ gridSelectionActiveRef,
1239
+ handlePromptEscape,
1240
+ handlePromptHistoryNavigate,
1241
+ handlePromptInterrupt,
1242
+ handlePromptPaste,
1243
+ hookPrompt,
1244
+ initialStatusLine,
1245
+ onPromptDraftChange,
1246
+ onSubmit,
1247
+ overlayHintAttachItemIndex,
1248
+ overlayHintFallbackRow,
1249
+ overlayHintOnLastItem,
1250
+ panelInkMaskEpoch,
1251
+ panelTransitionEpoch,
1252
+ picker,
1253
+ pickerOpenedFromEnterRef,
1254
+ pickerOpenedFromEnterTimerRef,
1255
+ promptBoxRectRef,
1256
+ promptDraft,
1257
+ promptDraftOverride,
1258
+ promptMouseSelectionRef,
1259
+ promptSelectionRef,
1260
+ promptValueRef,
1261
+ providerPrompt,
1262
+ renderedTranscriptItems,
1263
+ resizeEpoch,
1264
+ resizeState,
1265
+ restoreQueuedToPrompt,
1266
+ setPicker,
1267
+ setSlashIndex,
1268
+ setTextEntryLayoutRows,
1269
+ settingsPrompt,
1270
+ slashCommands,
1271
+ slashIndex,
1272
+ slashPaletteOpen,
1273
+ state,
1274
+ statuslineStats,
1275
+ store,
1276
+ toolApproval,
1277
+ toolOutputExpanded,
1278
+ transcriptMeasureRef,
1279
+ transcriptTailPinned,
1280
+ transcriptWindow,
1281
+ tuiReady,
1282
+ usagePanel,
1283
+ });
3491
1284
  }