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,403 @@
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 { forceRedraw, useInput } from '../vendor/cvc-ink/index.js';
6
+ import { useStore } from '@nanostores/react';
7
+ import { useEffect, useRef } from 'react';
8
+ import { TYPING_IDLE_MS } from '../config/timing.js';
9
+ import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js';
10
+ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js';
11
+ import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js';
12
+ import { getInputSelection } from './inputSelectionStore.js';
13
+ import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js';
14
+ import { turnController } from './turnController.js';
15
+ import { patchTurnState } from './turnStore.js';
16
+ import { getUiState } from './uiStore.js';
17
+ const isCtrl = (key, ch, target) => key.ctrl && ch.toLowerCase() === target;
18
+ export function applyVoiceRecordResponse(response, starting, voice, sys) {
19
+ if (!starting || response?.status === 'recording') {
20
+ return;
21
+ }
22
+ voice.setRecording(false);
23
+ if (response?.status === 'busy') {
24
+ voice.setProcessing(true);
25
+ sys('voice: still transcribing; try again shortly');
26
+ }
27
+ else {
28
+ voice.setProcessing(false);
29
+ }
30
+ }
31
+ export function useInputHandlers(ctx) {
32
+ const { actions, composer, gateway, terminal, voice, wheelStep } = ctx;
33
+ const { actions: cActions, refs: cRefs, state: cState } = composer;
34
+ const overlay = useStore($overlayState);
35
+ const isBlocked = useStore($isBlocked);
36
+ const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6);
37
+ const scrollIdleTimer = useRef(null);
38
+ // Wheel accel ported from claude-code: inter-event timing drives step size,
39
+ // direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
40
+ // rows = wheelStep × accelMult. State mutates in place across renders.
41
+ const wheelAccelRef = useRef(initWheelAccelForHost());
42
+ const precisionWheelRef = useRef(initPrecisionWheel());
43
+ useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), []);
44
+ const scrollTranscript = (delta) => {
45
+ if (getUiState().busy) {
46
+ turnController.boostStreamingForScroll();
47
+ clearTimeout(scrollIdleTimer.current ?? undefined);
48
+ scrollIdleTimer.current = setTimeout(() => {
49
+ scrollIdleTimer.current = null;
50
+ turnController.relaxStreaming();
51
+ }, TYPING_IDLE_MS);
52
+ }
53
+ terminal.scrollWithSelection(delta);
54
+ };
55
+ const copySelection = () => {
56
+ // ink's copySelection() already calls setClipboard() which handles
57
+ // pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
58
+ terminal.selection.copySelection();
59
+ };
60
+ const clearSelection = () => {
61
+ terminal.selection.clearSelection();
62
+ };
63
+ const cancelOverlayFromCtrlC = () => {
64
+ if (overlay.clarify) {
65
+ return actions.answerClarify('');
66
+ }
67
+ if (overlay.approval) {
68
+ return gateway
69
+ .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid })
70
+ .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })));
71
+ }
72
+ if (overlay.sudo) {
73
+ return gateway
74
+ .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
75
+ .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')));
76
+ }
77
+ if (overlay.secret) {
78
+ return gateway
79
+ .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' })
80
+ .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled')));
81
+ }
82
+ if (overlay.modelPicker) {
83
+ return patchOverlayState({ modelPicker: false });
84
+ }
85
+ if (overlay.skillsHub) {
86
+ return patchOverlayState({ skillsHub: false });
87
+ }
88
+ if (overlay.picker) {
89
+ return patchOverlayState({ picker: false });
90
+ }
91
+ if (overlay.agents) {
92
+ return patchOverlayState({ agents: false });
93
+ }
94
+ };
95
+ const cycleQueue = (dir) => {
96
+ const len = cRefs.queueRef.current.length;
97
+ if (!len) {
98
+ return false;
99
+ }
100
+ const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len;
101
+ cActions.setQueueEdit(index);
102
+ cActions.setHistoryIdx(null);
103
+ cActions.setInput(cRefs.queueRef.current[index] ?? '');
104
+ return true;
105
+ };
106
+ const cycleHistory = (dir) => {
107
+ const h = cRefs.historyRef.current;
108
+ const cur = cState.historyIdx;
109
+ if (dir < 0) {
110
+ if (!h.length) {
111
+ return;
112
+ }
113
+ if (cur === null) {
114
+ cRefs.historyDraftRef.current = cState.input;
115
+ }
116
+ const index = cur === null ? h.length - 1 : Math.max(0, cur - 1);
117
+ cActions.setHistoryIdx(index);
118
+ cActions.setQueueEdit(null);
119
+ cActions.setInput(h[index] ?? '');
120
+ return;
121
+ }
122
+ if (cur === null) {
123
+ return;
124
+ }
125
+ const next = cur + 1;
126
+ if (next >= h.length) {
127
+ cActions.setHistoryIdx(null);
128
+ cActions.setInput(cRefs.historyDraftRef.current);
129
+ }
130
+ else {
131
+ cActions.setHistoryIdx(next);
132
+ cActions.setInput(h[next] ?? '');
133
+ }
134
+ };
135
+ // CLI parity: Ctrl+B toggles a VAD-bounded push-to-talk capture
136
+ // (NOT the voice-mode umbrella bit). The mode is enabled via /voice on;
137
+ // Ctrl+B while the mode is off sys-nudges the user. While the mode is
138
+ // on, the first press starts a single VAD-bounded capture
139
+ // (gateway -> start_continuous(auto_restart=false), VAD auto-stop ->
140
+ // transcribe -> idle), a subsequent press stops and transcribes it.
141
+ // The gateway publishes voice.status + voice.transcript events that
142
+ // createGatewayEventHandler turns into UI badges and composer injection.
143
+ const voiceRecordToggle = () => {
144
+ if (!voice.enabled) {
145
+ return actions.sys('voice: mode is off — enable with /voice on');
146
+ }
147
+ const starting = !voice.recording;
148
+ const action = starting ? 'start' : 'stop';
149
+ // Optimistic UI — flip the REC badge immediately so the user gets
150
+ // feedback while the RPC round-trips; the voice.status event is the
151
+ // authoritative source and may correct us.
152
+ if (starting) {
153
+ voice.setRecording(true);
154
+ }
155
+ else {
156
+ voice.setRecording(false);
157
+ voice.setProcessing(false);
158
+ }
159
+ gateway
160
+ .rpc('voice.record', { action, session_id: getUiState().sid })
161
+ .then(r => applyVoiceRecordResponse(r, starting, voice, actions.sys))
162
+ .catch((e) => {
163
+ // Revert optimistic UI on failure.
164
+ if (starting) {
165
+ voice.setRecording(false);
166
+ }
167
+ actions.sys(`voice error: ${e.message}`);
168
+ });
169
+ };
170
+ useInput((ch, key) => {
171
+ const live = getUiState();
172
+ if (isBlocked) {
173
+ // When approval/clarify/confirm overlays are active, their own useInput
174
+ // handlers must receive keystrokes (arrow keys, numbers, Enter). Only
175
+ // intercept Ctrl+C here so the user can deny/dismiss — all other keys
176
+ // fall through to the component-level handlers.
177
+ if (overlay.approval || overlay.clarify || overlay.confirm) {
178
+ if (isCtrl(key, ch, 'c')) {
179
+ cancelOverlayFromCtrlC();
180
+ }
181
+ return;
182
+ }
183
+ if (overlay.pager) {
184
+ if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') {
185
+ return patchOverlayState({ pager: null });
186
+ }
187
+ const move = (delta) => patchOverlayState(prev => {
188
+ if (!prev.pager) {
189
+ return prev;
190
+ }
191
+ const { lines, offset } = prev.pager;
192
+ const max = Math.max(0, lines.length - pagerPageSize);
193
+ const step = delta === 'top' ? -lines.length : delta === 'bottom' ? lines.length : delta;
194
+ const next = Math.max(0, Math.min(offset + step, max));
195
+ return next === offset ? prev : { ...prev, pager: { ...prev.pager, offset: next } };
196
+ });
197
+ if (key.upArrow || ch === 'k') {
198
+ return move(-1);
199
+ }
200
+ if (key.downArrow || ch === 'j') {
201
+ return move(1);
202
+ }
203
+ if (key.pageUp || ch === 'b') {
204
+ return move(-pagerPageSize);
205
+ }
206
+ if (ch === 'g') {
207
+ return move('top');
208
+ }
209
+ if (ch === 'G') {
210
+ return move('bottom');
211
+ }
212
+ if (key.return || ch === ' ' || key.pageDown) {
213
+ patchOverlayState(prev => {
214
+ if (!prev.pager) {
215
+ return prev;
216
+ }
217
+ const { lines, offset } = prev.pager;
218
+ const max = Math.max(0, lines.length - pagerPageSize);
219
+ // Auto-close only when already at the last page — otherwise clamp
220
+ // to `max` so the offset matches what the line/page-back handlers
221
+ // can reach (prevents a snap-back jump on the next ↑/↓/PgUp).
222
+ return offset >= max
223
+ ? { ...prev, pager: null }
224
+ : { ...prev, pager: { ...prev.pager, offset: Math.min(offset + pagerPageSize, max) } };
225
+ });
226
+ }
227
+ return;
228
+ }
229
+ if (isCtrl(key, ch, 'c')) {
230
+ cancelOverlayFromCtrlC();
231
+ }
232
+ else if (key.escape && overlay.picker) {
233
+ patchOverlayState({ picker: false });
234
+ }
235
+ return;
236
+ }
237
+ if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
238
+ const len = cState.completions.length;
239
+ cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len));
240
+ return;
241
+ }
242
+ if (key.wheelUp || key.wheelDown) {
243
+ const dir = key.wheelUp ? -1 : 1;
244
+ const now = Date.now();
245
+ // Modifier-held wheel = precision mode: one row per frame, no accel.
246
+ // Smooth mice / trackpads emit tiny same-frame bursts; coalesce those
247
+ // without the old 80ms throttle that made opt-scroll feel stepped.
248
+ // SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
249
+ // macOS is intercepted by the terminal, so we honor Option (meta) on
250
+ // Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
251
+ // is reserved for selection extension.
252
+ const hasModifier = key.meta || key.ctrl;
253
+ const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now);
254
+ if (precision.active) {
255
+ // Entering precision mode must discard any accelerated wheel state;
256
+ // otherwise the next normal wheel event inherits stale momentum.
257
+ if (precision.entered) {
258
+ wheelAccelRef.current = initWheelAccelForHost();
259
+ }
260
+ return precision.rows ? scrollTranscript(dir * wheelStep) : undefined;
261
+ }
262
+ // 0 = direction-flip bounce deferred; skip the no-op scroll.
263
+ const rows = computeWheelStep(wheelAccelRef.current, dir, now);
264
+ return rows ? scrollTranscript(dir * rows * wheelStep) : undefined;
265
+ }
266
+ if (key.shift && key.upArrow) {
267
+ return scrollTranscript(-1);
268
+ }
269
+ if (key.shift && key.downArrow) {
270
+ return scrollTranscript(1);
271
+ }
272
+ if (key.pageUp || key.pageDown) {
273
+ // Half-viewport keeps 50% continuity and stays under Ink's
274
+ // `delta < innerHeight` DECSTBM fast-path threshold.
275
+ const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8);
276
+ const step = Math.max(4, Math.floor(viewport / 2));
277
+ return scrollTranscript(key.pageUp ? -step : step);
278
+ }
279
+ // Escape-based voice bindings (ctrl/alt/super+escape) must win before the
280
+ // generic Esc handlers below; otherwise queue-edit cancel / selection-clear
281
+ // would swallow the chord and /voice would advertise a shortcut that never
282
+ // actually toggles recording in those UI states.
283
+ if (key.escape && isVoiceToggleKey(key, ch, voice.recordKey)) {
284
+ return voiceRecordToggle();
285
+ }
286
+ // Queue-edit cancel beats selection-clear for plain Esc: the queue header
287
+ // explicitly promises "Esc cancel", so honoring it takes priority over the
288
+ // implicit selection-dismissal convention. Without an active edit, fall through.
289
+ if (key.escape && cState.queueEditIdx !== null) {
290
+ return cActions.clearIn();
291
+ }
292
+ if (key.escape && terminal.hasSelection) {
293
+ return clearSelection();
294
+ }
295
+ if (key.upArrow && !cState.inputBuf.length) {
296
+ const inputSel = getInputSelection();
297
+ const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null;
298
+ const noLineAbove = !cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0);
299
+ if (noLineAbove) {
300
+ cycleQueue(1) || cycleHistory(-1);
301
+ return;
302
+ }
303
+ }
304
+ if (key.downArrow && !cState.inputBuf.length) {
305
+ const inputSel = getInputSelection();
306
+ const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null;
307
+ const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0);
308
+ if (noLineBelow || cState.historyIdx !== null) {
309
+ cycleQueue(-1) || cycleHistory(1);
310
+ return;
311
+ }
312
+ }
313
+ if (isCopyShortcut(key, ch)) {
314
+ if (terminal.hasSelection) {
315
+ return copySelection();
316
+ }
317
+ const inputSel = getInputSelection();
318
+ if (inputSel && inputSel.end > inputSel.start) {
319
+ inputSel.clear();
320
+ return;
321
+ }
322
+ // On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt).
323
+ // On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit.
324
+ if (isMac) {
325
+ return;
326
+ }
327
+ }
328
+ if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
329
+ cActions.removeQueue(cState.queueEditIdx);
330
+ return cActions.clearIn();
331
+ }
332
+ if (key.ctrl && ch.toLowerCase() === 'c') {
333
+ if (live.busy && live.sid) {
334
+ return turnController.interruptTurn({
335
+ appendMessage: actions.appendMessage,
336
+ gw: gateway.gw,
337
+ sid: live.sid,
338
+ sys: actions.sys
339
+ });
340
+ }
341
+ if (cState.input || cState.inputBuf.length) {
342
+ return cActions.clearIn();
343
+ }
344
+ return actions.die();
345
+ }
346
+ if (isAction(key, ch, 'd')) {
347
+ return actions.die();
348
+ }
349
+ if (isAction(key, ch, 'l')) {
350
+ clearSelection();
351
+ forceRedraw(terminal.stdout ?? process.stdout);
352
+ return;
353
+ }
354
+ if (isVoiceToggleKey(key, ch, voice.recordKey)) {
355
+ return voiceRecordToggle();
356
+ }
357
+ // Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the
358
+ // primary keystroke to "Find Next" before the TUI sees it; Alt+G
359
+ // arrives as meta+g across platforms).
360
+ if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) {
361
+ return void cActions.openEditor().catch((err) => {
362
+ actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor');
363
+ });
364
+ }
365
+ // shift-tab flips yolo without spending a turn (claude-code parity)
366
+ if (key.shift && key.tab && !cState.completions.length) {
367
+ if (!live.sid) {
368
+ return void actions.sys('yolo needs an active session');
369
+ }
370
+ // gateway.rpc swallows errors with its own sys() message and resolves to null,
371
+ // so we only speak when it came back with a real shape. null = rpc already spoke.
372
+ return void gateway.rpc('config.set', { key: 'yolo', session_id: live.sid }).then(r => {
373
+ if (r?.value === '1') {
374
+ return actions.sys('yolo on');
375
+ }
376
+ if (r?.value === '0') {
377
+ return actions.sys('yolo off');
378
+ }
379
+ if (r) {
380
+ actions.sys('failed to toggle yolo');
381
+ }
382
+ });
383
+ }
384
+ if (key.tab && cState.completions.length) {
385
+ const row = cState.completions[cState.compIdx];
386
+ if (row?.text) {
387
+ const text = cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0
388
+ ? row.text.slice(1)
389
+ : row.text;
390
+ cActions.setInput(cState.input.slice(0, cState.compReplace) + text);
391
+ }
392
+ return;
393
+ }
394
+ if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) {
395
+ const next = cActions.dequeue();
396
+ if (next) {
397
+ cActions.setQueueEdit(null);
398
+ actions.dispatchSubmission(next);
399
+ }
400
+ }
401
+ });
402
+ return { pagerPageSize };
403
+ }
@@ -0,0 +1,50 @@
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 { useEffect, useRef } from 'react';
6
+ import { LONG_RUN_CHARMS } from '../content/charms.js';
7
+ import { pick, toolTrailLabel } from '../lib/text.js';
8
+ import { turnController } from './turnController.js';
9
+ import { useTurnSelector } from './turnStore.js';
10
+ import { getUiState } from './uiStore.js';
11
+ const DELAY_MS = 8_000;
12
+ const INTERVAL_MS = 10_000;
13
+ const MAX_CHARMS_PER_TOOL = 2;
14
+ export function useLongRunToolCharms() {
15
+ const tools = useTurnSelector(state => state.tools);
16
+ const slots = useRef(new Map());
17
+ useEffect(() => {
18
+ if (!getUiState().busy || !tools.length) {
19
+ slots.current.clear();
20
+ return;
21
+ }
22
+ const tick = () => {
23
+ if (!getUiState().busy) {
24
+ slots.current.clear();
25
+ return;
26
+ }
27
+ const now = Date.now();
28
+ const liveIds = new Set(tools.map(t => t.id));
29
+ for (const key of Array.from(slots.current.keys())) {
30
+ if (!liveIds.has(key)) {
31
+ slots.current.delete(key);
32
+ }
33
+ }
34
+ for (const tool of tools) {
35
+ if (!tool.startedAt || now - tool.startedAt < DELAY_MS) {
36
+ continue;
37
+ }
38
+ const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 };
39
+ if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) {
40
+ continue;
41
+ }
42
+ slots.current.set(tool.id, { count: slot.count + 1, lastAt: now });
43
+ turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)`);
44
+ }
45
+ };
46
+ tick();
47
+ const id = setInterval(tick, 1000);
48
+ return () => clearInterval(id);
49
+ }, [tools]);
50
+ }