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