cvc-tui 0.4.4 → 0.4.7

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 (142) hide show
  1. package/NOTICES.md +13 -0
  2. package/dist/app/completion.js +102 -0
  3. package/dist/app/createGatewayEventHandler.js +508 -0
  4. package/dist/app/createSlashHandler.js +101 -0
  5. package/dist/app/delegationStore.js +51 -0
  6. package/dist/app/gatewayContext.js +17 -0
  7. package/dist/app/historyStore.js +123 -0
  8. package/dist/app/inputBuffer.js +120 -0
  9. package/dist/app/inputSelectionStore.js +8 -0
  10. package/dist/app/inputStore.js +28 -0
  11. package/dist/app/interfaces.js +6 -0
  12. package/dist/app/overlayStore.js +40 -0
  13. package/dist/app/promptStore.js +44 -0
  14. package/dist/app/queueStore.js +25 -0
  15. package/dist/app/scroll.js +44 -0
  16. package/dist/app/setupHandoff.js +28 -0
  17. package/dist/app/slash/commands/core.js +479 -0
  18. package/dist/app/slash/commands/debug.js +44 -0
  19. package/dist/app/slash/commands/ops.js +512 -0
  20. package/dist/app/slash/commands/session.js +431 -0
  21. package/dist/app/slash/commands/setup.js +20 -0
  22. package/dist/app/slash/commands/toggles.js +40 -0
  23. package/dist/app/slash/registry.js +18 -0
  24. package/dist/app/slash/types.js +1 -0
  25. package/dist/app/spawnHistoryStore.js +105 -0
  26. package/dist/app/turnController.js +650 -0
  27. package/dist/app/turnStore.js +48 -0
  28. package/dist/app/uiStore.js +36 -0
  29. package/dist/app/useComposerState.js +265 -0
  30. package/dist/app/useConfigSync.js +144 -0
  31. package/dist/app/useInputHandlers.js +403 -0
  32. package/dist/app/useLongRunToolCharms.js +50 -0
  33. package/dist/app/useMainApp.js +638 -0
  34. package/dist/app/useSessionLifecycle.js +175 -0
  35. package/dist/app/useSubmission.js +287 -0
  36. package/dist/app.js +15 -0
  37. package/dist/banner.js +63 -0
  38. package/dist/components/agentsOverlay.js +474 -0
  39. package/dist/components/appChrome.js +252 -0
  40. package/dist/components/appLayout.js +122 -0
  41. package/dist/components/appOverlays.js +65 -0
  42. package/dist/components/branding.js +97 -0
  43. package/dist/components/fpsOverlay.js +22 -0
  44. package/dist/components/helpHint.js +21 -0
  45. package/dist/components/markdown.js +501 -0
  46. package/dist/components/maskedPrompt.js +12 -0
  47. package/dist/components/messageLine.js +82 -0
  48. package/dist/components/modelPicker.js +254 -0
  49. package/dist/components/overlayControls.js +30 -0
  50. package/dist/components/overlays/confirmPrompt.js +25 -0
  51. package/dist/components/overlays/helpOverlay.js +76 -0
  52. package/dist/components/overlays/historySearch.js +49 -0
  53. package/dist/components/overlays/modelPicker.js +60 -0
  54. package/dist/components/overlays/overlayUtils.js +19 -0
  55. package/dist/components/overlays/secretPrompt.js +36 -0
  56. package/dist/components/overlays/sessionPicker.js +93 -0
  57. package/dist/components/overlays/skillsHub.js +71 -0
  58. package/dist/components/prompts.js +95 -0
  59. package/dist/components/queuedMessages.js +24 -0
  60. package/dist/components/sessionPicker.js +130 -0
  61. package/dist/components/skillsHub.js +165 -0
  62. package/dist/components/streamingAssistant.js +35 -0
  63. package/dist/components/streamingMarkdown.js +144 -0
  64. package/dist/components/textInput.js +794 -0
  65. package/dist/components/themed.js +12 -0
  66. package/dist/components/thinking.js +496 -0
  67. package/dist/components/todoPanel.js +40 -0
  68. package/dist/components/transcript.js +22 -0
  69. package/dist/config/env.js +18 -0
  70. package/dist/config/limits.js +22 -0
  71. package/dist/config/timing.js +25 -0
  72. package/dist/content/charms.js +5 -0
  73. package/dist/content/faces.js +21 -0
  74. package/dist/content/fortunes.js +29 -0
  75. package/dist/content/hotkeys.js +38 -0
  76. package/dist/content/placeholders.js +15 -0
  77. package/dist/content/setup.js +14 -0
  78. package/dist/content/verbs.js +41 -0
  79. package/dist/domain/details.js +53 -0
  80. package/dist/domain/messages.js +63 -0
  81. package/dist/domain/paths.js +16 -0
  82. package/dist/domain/providers.js +11 -0
  83. package/dist/domain/roles.js +6 -0
  84. package/dist/domain/slash.js +11 -0
  85. package/dist/domain/usage.js +1 -0
  86. package/dist/domain/viewport.js +33 -0
  87. package/dist/entry.js +64 -70236
  88. package/dist/gateway/client.js +312 -0
  89. package/dist/gatewayClient.js +574 -0
  90. package/dist/gatewayTypes.js +1 -0
  91. package/dist/hooks/useCompletion.js +86 -0
  92. package/dist/hooks/useGitBranch.js +58 -0
  93. package/dist/hooks/useInputHistory.js +12 -0
  94. package/dist/hooks/useQueue.js +57 -0
  95. package/dist/hooks/useVirtualHistory.js +401 -0
  96. package/dist/lib/circularBuffer.js +43 -0
  97. package/dist/lib/clipboard.js +126 -0
  98. package/dist/lib/editor.js +41 -0
  99. package/dist/lib/editor.test.js +58 -0
  100. package/dist/lib/emoji.js +49 -0
  101. package/dist/lib/externalCli.js +11 -0
  102. package/dist/lib/forceTruecolor.js +26 -0
  103. package/dist/lib/fpsStore.js +36 -0
  104. package/dist/lib/gracefulExit.js +29 -0
  105. package/dist/lib/history.js +69 -0
  106. package/dist/lib/inputMetrics.js +143 -0
  107. package/dist/lib/liveProgress.js +51 -0
  108. package/dist/lib/liveProgress.test.js +89 -0
  109. package/dist/lib/localSessionInfo.js +116 -0
  110. package/dist/lib/mathUnicode.js +685 -0
  111. package/dist/lib/memory.js +123 -0
  112. package/dist/lib/memoryMonitor.js +76 -0
  113. package/dist/lib/messages.js +3 -0
  114. package/dist/lib/messages.test.js +25 -0
  115. package/dist/lib/osc52.js +53 -0
  116. package/dist/lib/perfPane.js +94 -0
  117. package/dist/lib/platform.js +312 -0
  118. package/dist/lib/precisionWheel.js +25 -0
  119. package/dist/lib/react-devtools-stub.js +12 -0
  120. package/dist/lib/reasoning.js +39 -0
  121. package/dist/lib/rpc.js +26 -0
  122. package/dist/lib/subagentTree.js +287 -0
  123. package/dist/lib/syntax.js +89 -0
  124. package/dist/lib/terminalModes.js +46 -0
  125. package/dist/lib/terminalParity.js +48 -0
  126. package/dist/lib/terminalSetup.js +321 -0
  127. package/dist/lib/text.js +203 -0
  128. package/dist/lib/text.test.js +18 -0
  129. package/dist/lib/todo.js +2 -0
  130. package/dist/lib/todo.test.js +22 -0
  131. package/dist/lib/viewportStore.js +82 -0
  132. package/dist/lib/virtualHeights.js +61 -0
  133. package/dist/lib/wheelAccel.js +143 -0
  134. package/dist/protocol/interpolation.js +4 -0
  135. package/dist/protocol/paste.js +3 -0
  136. package/dist/theme.js +398 -0
  137. package/dist/types.js +1 -0
  138. package/dist/vendor/cvc-ink/dist/entry-exports.js +52737 -0
  139. package/dist/vendor/cvc-ink/index.js +1 -0
  140. package/dist/vendor/cvc-ink/package.json +9 -0
  141. package/dist/vendor/cvc-ink/text-input.js +1 -0
  142. package/package.json +9 -9
@@ -0,0 +1,638 @@
1
+ // @ts-nocheck
2
+ // SPDX-License-Identifier: MIT
3
+ // Ported from CVC Agent (https://github.com/NousResearch/cvc)
4
+ // Original Copyright (c) 2025 Nous Research. CVC adaptations (c) 2026 Jai Kumar Meena.
5
+ import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '../vendor/cvc-ink/index.js';
6
+ import { useStore } from '@nanostores/react';
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+ import { STARTUP_RESUME_ID } from '../config/env.js';
9
+ import { FULL_RENDER_TAIL_ITEMS, MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js';
10
+ import { SECTION_NAMES, sectionMode } from '../domain/details.js';
11
+ import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js';
12
+ import { fmtCwdBranch, shortCwd } from '../domain/paths.js';
13
+ import { useGitBranch } from '../hooks/useGitBranch.js';
14
+ import { useVirtualHistory } from '../hooks/useVirtualHistory.js';
15
+ import { composerPromptWidth } from '../lib/inputMetrics.js';
16
+ import { appendTranscriptMessage } from '../lib/messages.js';
17
+ import { DEFAULT_VOICE_RECORD_KEY, isMac } from '../lib/platform.js';
18
+ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js';
19
+ import { terminalParityHints } from '../lib/terminalParity.js';
20
+ import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js';
21
+ import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js';
22
+ import { createGatewayEventHandler } from './createGatewayEventHandler.js';
23
+ import { createSlashHandler } from './createSlashHandler.js';
24
+ import { getInputSelection } from './inputSelectionStore.js';
25
+ import { $overlayState, patchOverlayState } from './overlayStore.js';
26
+ import { scrollWithSelectionBy } from './scroll.js';
27
+ import { turnController } from './turnController.js';
28
+ import { patchTurnState, useTurnSelector } from './turnStore.js';
29
+ import { $uiState, getUiState, patchUiState } from './uiStore.js';
30
+ import { useComposerState } from './useComposerState.js';
31
+ import { useConfigSync } from './useConfigSync.js';
32
+ import { useInputHandlers } from './useInputHandlers.js';
33
+ import { useLongRunToolCharms } from './useLongRunToolCharms.js';
34
+ import { useSessionLifecycle } from './useSessionLifecycle.js';
35
+ import { useSubmission } from './useSubmission.js';
36
+ const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i;
37
+ const BRACKET_PASTE_ON = '\x1b[?2004h';
38
+ const BRACKET_PASTE_OFF = '\x1b[?2004l';
39
+ const MAX_HEIGHT_CACHE_BUCKETS = 12;
40
+ const capHistory = (items) => {
41
+ if (items.length <= MAX_HISTORY) {
42
+ return items;
43
+ }
44
+ return items[0]?.kind === 'intro' ? [items[0], ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY);
45
+ };
46
+ const statusColorOf = (status, t) => {
47
+ if (status === 'ready') {
48
+ return t.ok;
49
+ }
50
+ if (status.startsWith('error')) {
51
+ return t.error;
52
+ }
53
+ if (status === 'interrupted') {
54
+ return t.warn;
55
+ }
56
+ return t.muted;
57
+ };
58
+ export function useMainApp(gw) {
59
+ const { exit } = useApp();
60
+ const { stdout } = useStdout();
61
+ const [cols, setCols] = useState(stdout?.columns ?? 80);
62
+ useEffect(() => {
63
+ if (!stdout) {
64
+ return;
65
+ }
66
+ const sync = () => setCols(stdout.columns ?? 80);
67
+ stdout.on('resize', sync);
68
+ if (stdout.isTTY) {
69
+ stdout.write(BRACKET_PASTE_ON);
70
+ }
71
+ return () => {
72
+ stdout.off('resize', sync);
73
+ if (stdout.isTTY) {
74
+ stdout.write(BRACKET_PASTE_OFF);
75
+ }
76
+ };
77
+ }, [stdout]);
78
+ const [historyItems, setHistoryItems] = useState(() => [{ kind: 'intro', role: 'system', text: '' }]);
79
+ const [lastUserMsg, setLastUserMsg] = useState('');
80
+ const [stickyPrompt, setStickyPrompt] = useState('');
81
+ const [catalog, setCatalog] = useState(null);
82
+ const [voiceEnabled, setVoiceEnabled] = useState(false);
83
+ const [voiceRecording, setVoiceRecording] = useState(false);
84
+ const [voiceProcessing, setVoiceProcessing] = useState(false);
85
+ const [voiceRecordKey, setVoiceRecordKey] = useState(DEFAULT_VOICE_RECORD_KEY);
86
+ const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now());
87
+ const [turnStartedAt, setTurnStartedAt] = useState(null);
88
+ const [goodVibesTick, setGoodVibesTick] = useState(0);
89
+ const [bellOnComplete, setBellOnComplete] = useState(false);
90
+ const ui = useStore($uiState);
91
+ const overlay = useStore($overlayState);
92
+ const turnLiveTailActive = useTurnSelector(state => Boolean(state.streaming ||
93
+ state.streamPendingTools.length ||
94
+ state.streamSegments.length ||
95
+ state.reasoning.trim() ||
96
+ state.reasoningActive ||
97
+ state.tools.length ||
98
+ state.subagents.length ||
99
+ state.todos.length));
100
+ const slashFlightRef = useRef(0);
101
+ const slashRef = useRef(() => false);
102
+ const colsRef = useRef(cols);
103
+ const scrollRef = useRef(null);
104
+ const onEventRef = useRef(() => { });
105
+ const clipboardPasteRef = useRef(() => { });
106
+ const submitRef = useRef(() => { });
107
+ const terminalHintsShownRef = useRef(new Set());
108
+ const historyItemsRef = useRef(historyItems);
109
+ const lastUserMsgRef = useRef(lastUserMsg);
110
+ const msgIdsRef = useRef(new WeakMap());
111
+ const msgIdSeqRef = useRef(0);
112
+ const heightCachesRef = useRef(new Map());
113
+ colsRef.current = cols;
114
+ historyItemsRef.current = historyItems;
115
+ lastUserMsgRef.current = lastUserMsg;
116
+ const hasSelection = useHasSelection();
117
+ const selection = useSelection();
118
+ const lastCopiedVersionRef = useRef(-1);
119
+ useEffect(() => {
120
+ selection.setSelectionBgColor(ui.theme.color.selectionBg);
121
+ }, [selection, ui.theme.color.selectionBg]);
122
+ // macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable
123
+ // mouse tracking, so the only reliable native-feeling path is iTerm-style
124
+ // copy-on-select: once a drag creates a stable TUI selection, write it to
125
+ // the system clipboard while keeping the highlight visible.
126
+ //
127
+ // Subscribe directly via the ink selection bus (not useSyncExternalStore)
128
+ // so React doesn't re-render MainApp on every drag-move tick. The version
129
+ // ref de-dupes against re-entrant notifications.
130
+ useEffect(() => {
131
+ if (!isMac) {
132
+ return;
133
+ }
134
+ return selection.subscribe(() => {
135
+ if (!selection.hasSelection()) {
136
+ return;
137
+ }
138
+ const state = selection.getState();
139
+ if (state?.isDragging) {
140
+ return;
141
+ }
142
+ const version = selection.version();
143
+ if (version === lastCopiedVersionRef.current) {
144
+ return;
145
+ }
146
+ lastCopiedVersionRef.current = version;
147
+ void selection.copySelectionNoClear();
148
+ });
149
+ }, [selection]);
150
+ const clearSelection = useCallback(() => {
151
+ selection.clearSelection();
152
+ getInputSelection()?.collapseToEnd();
153
+ }, [selection]);
154
+ const composer = useComposerState({
155
+ gw,
156
+ onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
157
+ onImageAttached: info => {
158
+ sys(attachedImageNotice(info));
159
+ },
160
+ submitRef
161
+ });
162
+ const { actions: composerActions, refs: composerRefs, state: composerState } = composer;
163
+ const empty = !historyItems.some(msg => msg.kind !== 'intro');
164
+ useEffect(() => {
165
+ void terminalParityHints()
166
+ .then(hints => {
167
+ for (const hint of hints) {
168
+ if (terminalHintsShownRef.current.has(hint.key)) {
169
+ continue;
170
+ }
171
+ terminalHintsShownRef.current.add(hint.key);
172
+ turnController.pushActivity(hint.message, hint.tone);
173
+ }
174
+ })
175
+ .catch(() => { });
176
+ }, []);
177
+ const messageId = useCallback((msg) => {
178
+ const hit = msgIdsRef.current.get(msg);
179
+ if (hit) {
180
+ return hit;
181
+ }
182
+ const next = `${messageHeightKey(msg)}:${++msgIdSeqRef.current}`;
183
+ msgIdsRef.current.set(msg, next);
184
+ return next;
185
+ }, []);
186
+ const virtualRows = useMemo(() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })), [historyItems, messageId]);
187
+ const detailsLayoutKey = useMemo(() => {
188
+ const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride);
189
+ const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride);
190
+ return `${thinking}:${tools}`;
191
+ }, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]);
192
+ const detailsVisible = detailsLayoutKey !== 'hidden:hidden';
193
+ const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt);
194
+ const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`;
195
+ const heightCache = useMemo(() => {
196
+ let cache = heightCachesRef.current.get(heightCacheKey);
197
+ if (!cache) {
198
+ cache = new Map();
199
+ heightCachesRef.current.set(heightCacheKey, cache);
200
+ if (heightCachesRef.current.size > MAX_HEIGHT_CACHE_BUCKETS) {
201
+ heightCachesRef.current.delete(heightCachesRef.current.keys().next().value);
202
+ }
203
+ }
204
+ return cache;
205
+ }, [heightCacheKey]);
206
+ // Index of the first user-role message — separator-rendering in
207
+ // appLayout.tsx skips this row, so the height estimator must skip it
208
+ // too. -1 when no user message exists yet (no row will gate true).
209
+ const firstUserIdx = useMemo(() => virtualRows.findIndex(r => r.msg.role === 'user'), [virtualRows]);
210
+ const estimateRowHeight = useCallback((index) => estimatedMsgHeight(virtualRows[index].msg, cols, {
211
+ compact: ui.compact,
212
+ details: detailsVisible,
213
+ limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,
214
+ userPrompt: ui.theme.brand.prompt,
215
+ withSeparator: virtualRows[index].msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
216
+ }), [cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows]);
217
+ const syncHeightCache = useCallback((heights) => {
218
+ for (const row of virtualRows) {
219
+ const h = heights.get(row.key);
220
+ if (h) {
221
+ heightCache.set(row.key, h);
222
+ }
223
+ }
224
+ }, [heightCache, virtualRows]);
225
+ const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, {
226
+ estimateHeight: estimateRowHeight,
227
+ initialHeights: heightCache,
228
+ liveTailActive: turnLiveTailActive,
229
+ onHeightsChange: syncHeightCache
230
+ });
231
+ const scrollWithSelection = useCallback((delta) => scrollWithSelectionBy(delta, { scrollRef, selection }), [selection]);
232
+ const appendMessage = useCallback((msg) => setHistoryItems(prev => capHistory(appendTranscriptMessage(prev, msg))), []);
233
+ const sys = useCallback((text) => appendMessage({ role: 'system', text }), [appendMessage]);
234
+ const page = useCallback((text, title) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), []);
235
+ const panel = useCallback((title, sections) => appendMessage({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), [appendMessage]);
236
+ const maybeWarn = useCallback((value) => {
237
+ const warning = value?.warning;
238
+ if (typeof warning === 'string' && warning) {
239
+ sys(`warning: ${warning}`);
240
+ }
241
+ }, [sys]);
242
+ const maybeGoodVibes = useCallback((text) => {
243
+ if (GOOD_VIBES_RE.test(text)) {
244
+ setGoodVibesTick(v => v + 1);
245
+ }
246
+ }, []);
247
+ const rpc = useCallback(async (method, params = {}) => {
248
+ try {
249
+ const result = asRpcResult(await gw.request(method, params));
250
+ if (result) {
251
+ return result;
252
+ }
253
+ sys(`error: invalid response: ${method}`);
254
+ }
255
+ catch (e) {
256
+ sys(`error: ${rpcErrorMessage(e)}`);
257
+ }
258
+ return null;
259
+ }, [gw, sys]);
260
+ const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]);
261
+ const die = useCallback(() => {
262
+ gw.kill();
263
+ exit();
264
+ // Ink's exit() calls unmount() which resets terminal modes but does NOT
265
+ // call process.exit(). Without an explicit exit the Node process stays
266
+ // alive (stdin listener keeps the event loop open), so the process.on('exit')
267
+ // handler in entry.tsx — which sends the final resetTerminalModes() — never
268
+ // fires. This leaves kitty keyboard protocol, mouse modes, etc. enabled
269
+ // in the parent shell. See issue #19194.
270
+ process.exit(0);
271
+ }, [exit, gw]);
272
+ const session = useSessionLifecycle({
273
+ colsRef,
274
+ composerActions,
275
+ gw,
276
+ panel,
277
+ rpc,
278
+ scrollRef,
279
+ setHistoryItems,
280
+ setLastUserMsg,
281
+ setSessionStartedAt,
282
+ setStickyPrompt,
283
+ setVoiceProcessing,
284
+ setVoiceRecording,
285
+ sys
286
+ });
287
+ useEffect(() => {
288
+ if (ui.busy) {
289
+ setTurnStartedAt(prev => prev ?? Date.now());
290
+ }
291
+ else {
292
+ setTurnStartedAt(null);
293
+ }
294
+ }, [ui.busy]);
295
+ useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid });
296
+ // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
297
+ const model = ui.info?.model?.replace(/^.*\//, '') ?? '';
298
+ const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓';
299
+ const tabCwd = ui.info?.cwd;
300
+ useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'CVC');
301
+ useEffect(() => {
302
+ if (!ui.sid || !stdout) {
303
+ return;
304
+ }
305
+ let timer;
306
+ const onResize = () => {
307
+ clearTimeout(timer);
308
+ timer = setTimeout(() => {
309
+ timer = undefined;
310
+ void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid });
311
+ }, 100);
312
+ };
313
+ stdout.on('resize', onResize);
314
+ return () => {
315
+ clearTimeout(timer);
316
+ stdout.off('resize', onResize);
317
+ };
318
+ }, [rpc, stdout, ui.sid]);
319
+ const answerClarify = useCallback((answer) => {
320
+ const clarify = overlay.clarify;
321
+ if (!clarify) {
322
+ return;
323
+ }
324
+ const label = toolTrailLabel('clarify');
325
+ turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line));
326
+ patchTurnState({ turnTrail: turnController.turnTools });
327
+ rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {
328
+ if (!r) {
329
+ return;
330
+ }
331
+ if (answer) {
332
+ turnController.persistedToolLabels.add(label);
333
+ appendMessage({
334
+ kind: 'trail',
335
+ role: 'system',
336
+ text: '',
337
+ tools: [buildToolTrailLine('clarify', clarify.question)]
338
+ });
339
+ appendMessage({ role: 'user', text: answer });
340
+ patchUiState({ status: 'running…' });
341
+ }
342
+ else {
343
+ sys('prompt cancelled');
344
+ }
345
+ patchOverlayState({ clarify: null });
346
+ });
347
+ }, [appendMessage, overlay.clarify, rpc, sys]);
348
+ const paste = useCallback((quiet = false) => rpc('clipboard.paste', { session_id: getUiState().sid }).then(r => {
349
+ if (!r) {
350
+ return;
351
+ }
352
+ if (r.attached) {
353
+ const meta = imageTokenMeta(r);
354
+ return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`);
355
+ }
356
+ if (!quiet) {
357
+ sys(r.message || 'No image found in clipboard');
358
+ }
359
+ }), [rpc, sys]);
360
+ clipboardPasteRef.current = paste;
361
+ const { dispatchSubmission, send, sendQueued, submit } = useSubmission({
362
+ appendMessage,
363
+ composerActions,
364
+ composerRefs,
365
+ composerState,
366
+ gw,
367
+ maybeGoodVibes,
368
+ setLastUserMsg,
369
+ slashRef,
370
+ submitRef,
371
+ sys
372
+ });
373
+ // Drain one queued message whenever the session settles (busy → false):
374
+ // agent turn ends, interrupt, shell.exec finishes, error recovered, or the
375
+ // session first comes up with pre-queued messages. Without this, shell.exec
376
+ // and error paths never emit message.complete, so anything enqueued while
377
+ // `!sleep` / a failed turn was running would stay stuck forever.
378
+ useEffect(() => {
379
+ if (!ui.sid ||
380
+ ui.busy ||
381
+ composerRefs.queueEditRef.current !== null ||
382
+ composerRefs.queueRef.current.length === 0) {
383
+ return;
384
+ }
385
+ const next = composerActions.dequeue();
386
+ if (next) {
387
+ patchUiState({ busy: true, status: 'running…' });
388
+ sendQueued(next);
389
+ }
390
+ }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]);
391
+ const { pagerPageSize } = useInputHandlers({
392
+ actions: {
393
+ answerClarify,
394
+ appendMessage,
395
+ die,
396
+ dispatchSubmission,
397
+ guardBusySessionSwitch: session.guardBusySessionSwitch,
398
+ newSession: session.newSession,
399
+ sys
400
+ },
401
+ composer: { actions: composerActions, refs: composerRefs, state: composerState },
402
+ gateway,
403
+ terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout },
404
+ voice: {
405
+ enabled: voiceEnabled,
406
+ recordKey: voiceRecordKey,
407
+ recording: voiceRecording,
408
+ setProcessing: setVoiceProcessing,
409
+ setRecording: setVoiceRecording,
410
+ setVoiceEnabled
411
+ },
412
+ wheelStep: WHEEL_SCROLL_STEP
413
+ });
414
+ const onEvent = useMemo(() => createGatewayEventHandler({
415
+ composer: { setInput: composerActions.setInput },
416
+ gateway,
417
+ session: {
418
+ STARTUP_RESUME_ID,
419
+ colsRef,
420
+ newSession: session.newSession,
421
+ resetSession: session.resetSession,
422
+ resumeById: session.resumeById,
423
+ setCatalog
424
+ },
425
+ submission: { submitRef },
426
+ system: { bellOnComplete, stdout, sys },
427
+ transcript: { appendMessage, panel, setHistoryItems },
428
+ voice: {
429
+ setProcessing: setVoiceProcessing,
430
+ setRecording: setVoiceRecording,
431
+ setVoiceEnabled
432
+ }
433
+ }), [
434
+ appendMessage,
435
+ bellOnComplete,
436
+ clearSelection,
437
+ composerActions.setInput,
438
+ gateway,
439
+ panel,
440
+ session.newSession,
441
+ session.resetSession,
442
+ session.resumeById,
443
+ setVoiceEnabled,
444
+ setVoiceProcessing,
445
+ setVoiceRecording,
446
+ stdout,
447
+ submitRef,
448
+ sys
449
+ ]);
450
+ onEventRef.current = onEvent;
451
+ useEffect(() => {
452
+ const handler = (ev) => onEventRef.current(ev);
453
+ // Track whether we've already surfaced a gateway-exit notice so we don't
454
+ // spam the activity log with red walls when the gateway is offline.
455
+ let gatewayExitedShown = false;
456
+ const exitHandler = () => {
457
+ turnController.reset();
458
+ patchUiState({
459
+ busy: false,
460
+ sid: null,
461
+ status: 'offline — type /start to launch gateway'
462
+ });
463
+ if (!gatewayExitedShown) {
464
+ gatewayExitedShown = true;
465
+ turnController.pushActivity('gateway offline · type /start to launch · /logs to inspect', 'info');
466
+ }
467
+ };
468
+ gw.on('event', handler);
469
+ gw.on('exit', exitHandler);
470
+ gw.drain();
471
+ // entry.tsx's setupGracefulExit handles process cleanup on real exit.
472
+ return () => {
473
+ gw.off('event', handler);
474
+ gw.off('exit', exitHandler);
475
+ };
476
+ }, [gw, sys]);
477
+ useLongRunToolCharms();
478
+ const slash = useMemo(() => createSlashHandler({
479
+ composer: {
480
+ enqueue: composerActions.enqueue,
481
+ hasSelection,
482
+ paste,
483
+ queueRef: composerRefs.queueRef,
484
+ selection,
485
+ setInput: composerActions.setInput
486
+ },
487
+ gateway,
488
+ local: {
489
+ catalog,
490
+ getHistoryItems: () => historyItemsRef.current,
491
+ getLastUserMsg: () => lastUserMsgRef.current,
492
+ maybeWarn,
493
+ setCatalog
494
+ },
495
+ session: {
496
+ closeSession: session.closeSession,
497
+ die,
498
+ guardBusySessionSwitch: session.guardBusySessionSwitch,
499
+ newSession: session.newSession,
500
+ resetVisibleHistory: session.resetVisibleHistory,
501
+ resumeById: session.resumeById,
502
+ setSessionStartedAt
503
+ },
504
+ slashFlightRef,
505
+ transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange },
506
+ voice: { setVoiceEnabled, setVoiceRecordKey }
507
+ }), [
508
+ catalog,
509
+ composerActions,
510
+ composerRefs,
511
+ die,
512
+ gateway,
513
+ hasSelection,
514
+ maybeWarn,
515
+ page,
516
+ panel,
517
+ paste,
518
+ selection,
519
+ send,
520
+ session,
521
+ sys
522
+ ]);
523
+ slashRef.current = slash;
524
+ const respondWith = useCallback((method, params, done) => rpc(method, params).then(r => r && done()), [rpc]);
525
+ const answerApproval = useCallback((choice) => respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
526
+ patchOverlayState({ approval: null });
527
+ patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` });
528
+ patchUiState({ status: 'running…' });
529
+ }), [respondWith, ui.sid]);
530
+ const answerSudo = useCallback((pw) => {
531
+ if (!overlay.sudo) {
532
+ return;
533
+ }
534
+ return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => {
535
+ patchOverlayState({ sudo: null });
536
+ patchUiState({ status: 'running…' });
537
+ });
538
+ }, [overlay.sudo, respondWith]);
539
+ const answerSecret = useCallback((value) => {
540
+ if (!overlay.secret) {
541
+ return;
542
+ }
543
+ return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => {
544
+ patchOverlayState({ secret: null });
545
+ patchUiState({ status: 'running…' });
546
+ });
547
+ }, [overlay.secret, respondWith]);
548
+ const onModelSelect = useCallback((value) => {
549
+ patchOverlayState({ modelPicker: false });
550
+ slashRef.current(`/model ${value}`);
551
+ }, []);
552
+ const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim()));
553
+ // Per-section overrides win over the global mode — when every section is
554
+ // resolved to hidden, the only thing ToolTrail will surface is the
555
+ // floating-alert backstop (errors/warnings). Mirror that so we don't
556
+ // render an empty wrapper Box above the streaming area in quiet mode.
557
+ const anyPanelVisible = SECTION_NAMES.some(s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden');
558
+ const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden';
559
+ const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden';
560
+ const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden';
561
+ const showProgressArea = useTurnSelector(state => anyPanelVisible
562
+ ? Boolean(ui.busy ||
563
+ state.outcome ||
564
+ state.streamPendingTools.length ||
565
+ state.streamSegments.some(segment => {
566
+ const hasThinking = Boolean(segment.thinking?.trim());
567
+ const hasTrailTools = Boolean(segment.tools?.length);
568
+ if (segment.kind === 'trail' && !segment.text) {
569
+ return ((thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools));
570
+ }
571
+ return (Boolean(segment.text?.trim()) ||
572
+ (thinkingPanelVisible && hasThinking) ||
573
+ ((toolsPanelVisible || activityPanelVisible) && hasTrailTools));
574
+ }) ||
575
+ state.subagents.length ||
576
+ state.tools.length ||
577
+ state.todos.length ||
578
+ state.turnTrail.length ||
579
+ (thinkingPanelVisible && hasReasoning) ||
580
+ state.activity.length)
581
+ : state.activity.some(item => item.tone !== 'info'));
582
+ const appActions = useMemo(() => ({
583
+ answerApproval,
584
+ answerClarify,
585
+ answerSecret,
586
+ answerSudo,
587
+ clearSelection,
588
+ onModelSelect,
589
+ resumeById: session.resumeById,
590
+ setStickyPrompt
591
+ }), [answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, onModelSelect, session.resumeById]);
592
+ const appComposer = useMemo(() => ({
593
+ cols,
594
+ compIdx: composerState.compIdx,
595
+ completions: composerState.completions,
596
+ empty,
597
+ handleTextPaste: composerActions.handleTextPaste,
598
+ input: composerState.input,
599
+ inputBuf: composerState.inputBuf,
600
+ pagerPageSize,
601
+ queueEditIdx: composerState.queueEditIdx,
602
+ queuedDisplay: composerState.queuedDisplay,
603
+ submit,
604
+ updateInput: composerActions.setInput,
605
+ voiceRecordKey
606
+ }), [cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey]);
607
+ // Pass current progress through unfrozen — streaming update throttling
608
+ // handles interaction load; progress must stay truthful so panels don't
609
+ // randomly disappear when the live tail scrolls offscreen.
610
+ const appProgress = useMemo(() => ({ showProgressArea }), [showProgressArea]);
611
+ const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd();
612
+ const gitBranch = useGitBranch(cwd);
613
+ const appStatus = useMemo(() => ({
614
+ cwdLabel: fmtCwdBranch(cwd, gitBranch),
615
+ goodVibesTick,
616
+ sessionStartedAt: ui.sid ? sessionStartedAt : null,
617
+ showStickyPrompt: !!stickyPrompt,
618
+ statusColor: statusColorOf(ui.status, ui.theme.color),
619
+ stickyPrompt,
620
+ turnStartedAt: ui.sid ? turnStartedAt : null,
621
+ // CLI parity: the classic prompt_toolkit status bar shows a red dot
622
+ // on REC (cli.py:_get_voice_status_fragments line 2344).
623
+ voiceLabel: voiceRecording ? '● REC' : voiceProcessing ? '◉ STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
624
+ }), [
625
+ cwd,
626
+ gitBranch,
627
+ goodVibesTick,
628
+ sessionStartedAt,
629
+ stickyPrompt,
630
+ turnStartedAt,
631
+ ui,
632
+ voiceEnabled,
633
+ voiceProcessing,
634
+ voiceRecording
635
+ ]);
636
+ const appTranscript = useMemo(() => ({ historyItems, scrollRef, virtualHistory, virtualRows }), [historyItems, virtualHistory, virtualRows]);
637
+ return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway };
638
+ }