cvc-tui 0.4.0 → 0.4.2

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 (133) hide show
  1. package/dist/entry.js +71148 -61
  2. package/package.json +2 -2
  3. package/dist/app/completion.js +0 -102
  4. package/dist/app/createGatewayEventHandler.js +0 -508
  5. package/dist/app/createSlashHandler.js +0 -101
  6. package/dist/app/delegationStore.js +0 -51
  7. package/dist/app/gatewayContext.js +0 -17
  8. package/dist/app/historyStore.js +0 -123
  9. package/dist/app/inputBuffer.js +0 -120
  10. package/dist/app/inputSelectionStore.js +0 -8
  11. package/dist/app/inputStore.js +0 -28
  12. package/dist/app/interfaces.js +0 -6
  13. package/dist/app/overlayStore.js +0 -40
  14. package/dist/app/promptStore.js +0 -44
  15. package/dist/app/queueStore.js +0 -25
  16. package/dist/app/scroll.js +0 -44
  17. package/dist/app/setupHandoff.js +0 -28
  18. package/dist/app/slash/commands/core.js +0 -479
  19. package/dist/app/slash/commands/debug.js +0 -44
  20. package/dist/app/slash/commands/ops.js +0 -498
  21. package/dist/app/slash/commands/session.js +0 -431
  22. package/dist/app/slash/commands/setup.js +0 -20
  23. package/dist/app/slash/commands/toggles.js +0 -40
  24. package/dist/app/slash/registry.js +0 -18
  25. package/dist/app/slash/types.js +0 -1
  26. package/dist/app/spawnHistoryStore.js +0 -105
  27. package/dist/app/turnController.js +0 -650
  28. package/dist/app/turnStore.js +0 -48
  29. package/dist/app/uiStore.js +0 -36
  30. package/dist/app/useComposerState.js +0 -265
  31. package/dist/app/useConfigSync.js +0 -144
  32. package/dist/app/useInputHandlers.js +0 -403
  33. package/dist/app/useLongRunToolCharms.js +0 -50
  34. package/dist/app/useMainApp.js +0 -629
  35. package/dist/app/useSessionLifecycle.js +0 -175
  36. package/dist/app/useSubmission.js +0 -287
  37. package/dist/app.js +0 -15
  38. package/dist/banner.js +0 -57
  39. package/dist/components/agentsOverlay.js +0 -474
  40. package/dist/components/appChrome.js +0 -252
  41. package/dist/components/appLayout.js +0 -121
  42. package/dist/components/appOverlays.js +0 -65
  43. package/dist/components/branding.js +0 -97
  44. package/dist/components/fpsOverlay.js +0 -22
  45. package/dist/components/helpHint.js +0 -21
  46. package/dist/components/markdown.js +0 -501
  47. package/dist/components/maskedPrompt.js +0 -12
  48. package/dist/components/messageLine.js +0 -82
  49. package/dist/components/modelPicker.js +0 -254
  50. package/dist/components/overlayControls.js +0 -30
  51. package/dist/components/overlays/confirmPrompt.js +0 -25
  52. package/dist/components/overlays/helpOverlay.js +0 -76
  53. package/dist/components/overlays/historySearch.js +0 -49
  54. package/dist/components/overlays/modelPicker.js +0 -60
  55. package/dist/components/overlays/overlayUtils.js +0 -19
  56. package/dist/components/overlays/secretPrompt.js +0 -36
  57. package/dist/components/overlays/sessionPicker.js +0 -93
  58. package/dist/components/overlays/skillsHub.js +0 -71
  59. package/dist/components/prompts.js +0 -95
  60. package/dist/components/queuedMessages.js +0 -24
  61. package/dist/components/sessionPicker.js +0 -130
  62. package/dist/components/skillsHub.js +0 -165
  63. package/dist/components/streamingAssistant.js +0 -35
  64. package/dist/components/streamingMarkdown.js +0 -144
  65. package/dist/components/textInput.js +0 -794
  66. package/dist/components/themed.js +0 -12
  67. package/dist/components/thinking.js +0 -496
  68. package/dist/components/todoPanel.js +0 -40
  69. package/dist/components/transcript.js +0 -22
  70. package/dist/config/env.js +0 -18
  71. package/dist/config/limits.js +0 -22
  72. package/dist/config/timing.js +0 -18
  73. package/dist/content/charms.js +0 -5
  74. package/dist/content/faces.js +0 -21
  75. package/dist/content/fortunes.js +0 -29
  76. package/dist/content/hotkeys.js +0 -38
  77. package/dist/content/placeholders.js +0 -15
  78. package/dist/content/setup.js +0 -14
  79. package/dist/content/verbs.js +0 -41
  80. package/dist/domain/details.js +0 -53
  81. package/dist/domain/messages.js +0 -63
  82. package/dist/domain/paths.js +0 -16
  83. package/dist/domain/providers.js +0 -11
  84. package/dist/domain/roles.js +0 -6
  85. package/dist/domain/slash.js +0 -11
  86. package/dist/domain/usage.js +0 -1
  87. package/dist/domain/viewport.js +0 -33
  88. package/dist/gateway/client.js +0 -312
  89. package/dist/gatewayClient.js +0 -574
  90. package/dist/gatewayTypes.js +0 -1
  91. package/dist/hooks/useCompletion.js +0 -86
  92. package/dist/hooks/useGitBranch.js +0 -58
  93. package/dist/hooks/useInputHistory.js +0 -12
  94. package/dist/hooks/useQueue.js +0 -57
  95. package/dist/hooks/useVirtualHistory.js +0 -401
  96. package/dist/lib/circularBuffer.js +0 -43
  97. package/dist/lib/clipboard.js +0 -126
  98. package/dist/lib/editor.js +0 -41
  99. package/dist/lib/editor.test.js +0 -58
  100. package/dist/lib/emoji.js +0 -49
  101. package/dist/lib/externalCli.js +0 -11
  102. package/dist/lib/forceTruecolor.js +0 -26
  103. package/dist/lib/fpsStore.js +0 -36
  104. package/dist/lib/gracefulExit.js +0 -29
  105. package/dist/lib/history.js +0 -69
  106. package/dist/lib/inputMetrics.js +0 -143
  107. package/dist/lib/liveProgress.js +0 -51
  108. package/dist/lib/liveProgress.test.js +0 -89
  109. package/dist/lib/mathUnicode.js +0 -685
  110. package/dist/lib/memory.js +0 -123
  111. package/dist/lib/memoryMonitor.js +0 -76
  112. package/dist/lib/messages.js +0 -3
  113. package/dist/lib/messages.test.js +0 -25
  114. package/dist/lib/osc52.js +0 -53
  115. package/dist/lib/perfPane.js +0 -94
  116. package/dist/lib/platform.js +0 -312
  117. package/dist/lib/precisionWheel.js +0 -25
  118. package/dist/lib/reasoning.js +0 -39
  119. package/dist/lib/rpc.js +0 -26
  120. package/dist/lib/subagentTree.js +0 -287
  121. package/dist/lib/syntax.js +0 -89
  122. package/dist/lib/terminalModes.js +0 -46
  123. package/dist/lib/terminalParity.js +0 -48
  124. package/dist/lib/terminalSetup.js +0 -321
  125. package/dist/lib/text.js +0 -203
  126. package/dist/lib/text.test.js +0 -18
  127. package/dist/lib/todo.js +0 -2
  128. package/dist/lib/todo.test.js +0 -22
  129. package/dist/lib/viewportStore.js +0 -82
  130. package/dist/lib/virtualHeights.js +0 -61
  131. package/dist/lib/wheelAccel.js +0 -143
  132. package/dist/theme.js +0 -398
  133. package/dist/types.js +0 -1
@@ -1,629 +0,0 @@
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 '@cvc/ink';
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
- const exitHandler = () => {
454
- turnController.reset();
455
- patchUiState({ busy: false, sid: null, status: 'gateway exited' });
456
- turnController.pushActivity('gateway exited · /logs to inspect', 'error');
457
- sys('error: gateway exited');
458
- };
459
- gw.on('event', handler);
460
- gw.on('exit', exitHandler);
461
- gw.drain();
462
- // entry.tsx's setupGracefulExit handles process cleanup on real exit.
463
- return () => {
464
- gw.off('event', handler);
465
- gw.off('exit', exitHandler);
466
- };
467
- }, [gw, sys]);
468
- useLongRunToolCharms();
469
- const slash = useMemo(() => createSlashHandler({
470
- composer: {
471
- enqueue: composerActions.enqueue,
472
- hasSelection,
473
- paste,
474
- queueRef: composerRefs.queueRef,
475
- selection,
476
- setInput: composerActions.setInput
477
- },
478
- gateway,
479
- local: {
480
- catalog,
481
- getHistoryItems: () => historyItemsRef.current,
482
- getLastUserMsg: () => lastUserMsgRef.current,
483
- maybeWarn,
484
- setCatalog
485
- },
486
- session: {
487
- closeSession: session.closeSession,
488
- die,
489
- guardBusySessionSwitch: session.guardBusySessionSwitch,
490
- newSession: session.newSession,
491
- resetVisibleHistory: session.resetVisibleHistory,
492
- resumeById: session.resumeById,
493
- setSessionStartedAt
494
- },
495
- slashFlightRef,
496
- transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange },
497
- voice: { setVoiceEnabled, setVoiceRecordKey }
498
- }), [
499
- catalog,
500
- composerActions,
501
- composerRefs,
502
- die,
503
- gateway,
504
- hasSelection,
505
- maybeWarn,
506
- page,
507
- panel,
508
- paste,
509
- selection,
510
- send,
511
- session,
512
- sys
513
- ]);
514
- slashRef.current = slash;
515
- const respondWith = useCallback((method, params, done) => rpc(method, params).then(r => r && done()), [rpc]);
516
- const answerApproval = useCallback((choice) => respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
517
- patchOverlayState({ approval: null });
518
- patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` });
519
- patchUiState({ status: 'running…' });
520
- }), [respondWith, ui.sid]);
521
- const answerSudo = useCallback((pw) => {
522
- if (!overlay.sudo) {
523
- return;
524
- }
525
- return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => {
526
- patchOverlayState({ sudo: null });
527
- patchUiState({ status: 'running…' });
528
- });
529
- }, [overlay.sudo, respondWith]);
530
- const answerSecret = useCallback((value) => {
531
- if (!overlay.secret) {
532
- return;
533
- }
534
- return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => {
535
- patchOverlayState({ secret: null });
536
- patchUiState({ status: 'running…' });
537
- });
538
- }, [overlay.secret, respondWith]);
539
- const onModelSelect = useCallback((value) => {
540
- patchOverlayState({ modelPicker: false });
541
- slashRef.current(`/model ${value}`);
542
- }, []);
543
- const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim()));
544
- // Per-section overrides win over the global mode — when every section is
545
- // resolved to hidden, the only thing ToolTrail will surface is the
546
- // floating-alert backstop (errors/warnings). Mirror that so we don't
547
- // render an empty wrapper Box above the streaming area in quiet mode.
548
- const anyPanelVisible = SECTION_NAMES.some(s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden');
549
- const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden';
550
- const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden';
551
- const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden';
552
- const showProgressArea = useTurnSelector(state => anyPanelVisible
553
- ? Boolean(ui.busy ||
554
- state.outcome ||
555
- state.streamPendingTools.length ||
556
- state.streamSegments.some(segment => {
557
- const hasThinking = Boolean(segment.thinking?.trim());
558
- const hasTrailTools = Boolean(segment.tools?.length);
559
- if (segment.kind === 'trail' && !segment.text) {
560
- return ((thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools));
561
- }
562
- return (Boolean(segment.text?.trim()) ||
563
- (thinkingPanelVisible && hasThinking) ||
564
- ((toolsPanelVisible || activityPanelVisible) && hasTrailTools));
565
- }) ||
566
- state.subagents.length ||
567
- state.tools.length ||
568
- state.todos.length ||
569
- state.turnTrail.length ||
570
- (thinkingPanelVisible && hasReasoning) ||
571
- state.activity.length)
572
- : state.activity.some(item => item.tone !== 'info'));
573
- const appActions = useMemo(() => ({
574
- answerApproval,
575
- answerClarify,
576
- answerSecret,
577
- answerSudo,
578
- clearSelection,
579
- onModelSelect,
580
- resumeById: session.resumeById,
581
- setStickyPrompt
582
- }), [answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, onModelSelect, session.resumeById]);
583
- const appComposer = useMemo(() => ({
584
- cols,
585
- compIdx: composerState.compIdx,
586
- completions: composerState.completions,
587
- empty,
588
- handleTextPaste: composerActions.handleTextPaste,
589
- input: composerState.input,
590
- inputBuf: composerState.inputBuf,
591
- pagerPageSize,
592
- queueEditIdx: composerState.queueEditIdx,
593
- queuedDisplay: composerState.queuedDisplay,
594
- submit,
595
- updateInput: composerActions.setInput,
596
- voiceRecordKey
597
- }), [cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey]);
598
- // Pass current progress through unfrozen — streaming update throttling
599
- // handles interaction load; progress must stay truthful so panels don't
600
- // randomly disappear when the live tail scrolls offscreen.
601
- const appProgress = useMemo(() => ({ showProgressArea }), [showProgressArea]);
602
- const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd();
603
- const gitBranch = useGitBranch(cwd);
604
- const appStatus = useMemo(() => ({
605
- cwdLabel: fmtCwdBranch(cwd, gitBranch),
606
- goodVibesTick,
607
- sessionStartedAt: ui.sid ? sessionStartedAt : null,
608
- showStickyPrompt: !!stickyPrompt,
609
- statusColor: statusColorOf(ui.status, ui.theme.color),
610
- stickyPrompt,
611
- turnStartedAt: ui.sid ? turnStartedAt : null,
612
- // CLI parity: the classic prompt_toolkit status bar shows a red dot
613
- // on REC (cli.py:_get_voice_status_fragments line 2344).
614
- voiceLabel: voiceRecording ? '● REC' : voiceProcessing ? '◉ STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
615
- }), [
616
- cwd,
617
- gitBranch,
618
- goodVibesTick,
619
- sessionStartedAt,
620
- stickyPrompt,
621
- turnStartedAt,
622
- ui,
623
- voiceEnabled,
624
- voiceProcessing,
625
- voiceRecording
626
- ]);
627
- const appTranscript = useMemo(() => ({ historyItems, scrollRef, virtualHistory, virtualRows }), [historyItems, virtualHistory, virtualRows]);
628
- return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway };
629
- }