cvc-tui 0.1.0 → 0.4.0

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 (130) hide show
  1. package/dist/app/completion.js +4 -0
  2. package/dist/app/createGatewayEventHandler.js +508 -0
  3. package/dist/app/createSlashHandler.js +101 -0
  4. package/dist/app/delegationStore.js +51 -0
  5. package/dist/app/gatewayContext.js +17 -0
  6. package/dist/app/historyStore.js +4 -0
  7. package/dist/app/inputBuffer.js +4 -0
  8. package/dist/app/inputSelectionStore.js +8 -0
  9. package/dist/app/inputStore.js +4 -0
  10. package/dist/app/interfaces.js +6 -0
  11. package/dist/app/overlayStore.js +40 -0
  12. package/dist/app/promptStore.js +4 -0
  13. package/dist/app/queueStore.js +4 -0
  14. package/dist/app/scroll.js +44 -0
  15. package/dist/app/setupHandoff.js +28 -0
  16. package/dist/app/slash/commands/core.js +421 -234
  17. package/dist/app/slash/commands/debug.js +39 -6
  18. package/dist/app/slash/commands/ops.js +471 -136
  19. package/dist/app/slash/commands/session.js +405 -65
  20. package/dist/app/slash/commands/setup.js +16 -43
  21. package/dist/app/slash/commands/toggles.js +4 -0
  22. package/dist/app/slash/registry.js +7 -68
  23. package/dist/app/slash/types.js +1 -16
  24. package/dist/app/spawnHistoryStore.js +105 -0
  25. package/dist/app/turnController.js +650 -0
  26. package/dist/app/turnStore.js +47 -59
  27. package/dist/app/uiStore.js +34 -29
  28. package/dist/app/useComposerState.js +265 -0
  29. package/dist/app/useConfigSync.js +144 -0
  30. package/dist/app/useInputHandlers.js +403 -0
  31. package/dist/app/useLongRunToolCharms.js +50 -0
  32. package/dist/app/useMainApp.js +629 -0
  33. package/dist/app/useSessionLifecycle.js +175 -0
  34. package/dist/app/useSubmission.js +287 -0
  35. package/dist/app.js +13 -217
  36. package/dist/banner.js +40 -3
  37. package/dist/components/agentsOverlay.js +474 -0
  38. package/dist/components/appChrome.js +252 -0
  39. package/dist/components/appLayout.js +121 -22
  40. package/dist/components/appOverlays.js +65 -0
  41. package/dist/components/branding.js +97 -6
  42. package/dist/components/fpsOverlay.js +22 -0
  43. package/dist/components/helpHint.js +21 -0
  44. package/dist/components/markdown.js +501 -0
  45. package/dist/components/maskedPrompt.js +12 -0
  46. package/dist/components/messageLine.js +82 -0
  47. package/dist/components/modelPicker.js +254 -0
  48. package/dist/components/overlayControls.js +30 -0
  49. package/dist/components/overlays/helpOverlay.js +1 -0
  50. package/dist/components/overlays/historySearch.js +1 -0
  51. package/dist/components/overlays/modelPicker.js +2 -1
  52. package/dist/components/overlays/overlayUtils.js +2 -1
  53. package/dist/components/overlays/secretPrompt.js +1 -0
  54. package/dist/components/overlays/sessionPicker.js +1 -0
  55. package/dist/components/overlays/skillsHub.js +1 -0
  56. package/dist/components/prompts.js +95 -0
  57. package/dist/components/queuedMessages.js +24 -0
  58. package/dist/components/sessionPicker.js +130 -0
  59. package/dist/components/skillsHub.js +165 -0
  60. package/dist/components/streamingAssistant.js +35 -0
  61. package/dist/components/streamingMarkdown.js +110 -186
  62. package/dist/components/textInput.js +748 -218
  63. package/dist/components/themed.js +12 -0
  64. package/dist/components/thinking.js +493 -36
  65. package/dist/components/todoPanel.js +40 -0
  66. package/dist/config/env.js +18 -0
  67. package/dist/config/limits.js +22 -0
  68. package/dist/config/timing.js +4 -0
  69. package/dist/content/charms.js +5 -0
  70. package/dist/content/faces.js +21 -0
  71. package/dist/content/fortunes.js +29 -0
  72. package/dist/content/hotkeys.js +38 -0
  73. package/dist/content/placeholders.js +15 -0
  74. package/dist/content/setup.js +14 -0
  75. package/dist/content/verbs.js +41 -0
  76. package/dist/domain/details.js +53 -0
  77. package/dist/domain/messages.js +63 -0
  78. package/dist/domain/paths.js +16 -0
  79. package/dist/domain/providers.js +11 -0
  80. package/dist/domain/roles.js +6 -0
  81. package/dist/domain/slash.js +11 -0
  82. package/dist/domain/usage.js +1 -0
  83. package/dist/domain/viewport.js +33 -0
  84. package/dist/entry.js +65 -40
  85. package/dist/gatewayClient.js +574 -0
  86. package/dist/gatewayTypes.js +1 -0
  87. package/dist/hooks/useCompletion.js +86 -0
  88. package/dist/hooks/useGitBranch.js +58 -0
  89. package/dist/hooks/useInputHistory.js +12 -0
  90. package/dist/hooks/useQueue.js +57 -0
  91. package/dist/hooks/useVirtualHistory.js +401 -0
  92. package/dist/lib/circularBuffer.js +43 -0
  93. package/dist/lib/clipboard.js +126 -0
  94. package/dist/lib/editor.js +41 -0
  95. package/dist/lib/editor.test.js +58 -0
  96. package/dist/lib/emoji.js +49 -0
  97. package/dist/lib/externalCli.js +11 -0
  98. package/dist/lib/forceTruecolor.js +26 -0
  99. package/dist/lib/fpsStore.js +36 -0
  100. package/dist/lib/gracefulExit.js +29 -0
  101. package/dist/lib/history.js +69 -0
  102. package/dist/lib/inputMetrics.js +143 -0
  103. package/dist/lib/liveProgress.js +51 -0
  104. package/dist/lib/liveProgress.test.js +89 -0
  105. package/dist/lib/mathUnicode.js +685 -0
  106. package/dist/lib/memory.js +123 -0
  107. package/dist/lib/memoryMonitor.js +76 -0
  108. package/dist/lib/messages.js +3 -0
  109. package/dist/lib/messages.test.js +25 -0
  110. package/dist/lib/osc52.js +53 -0
  111. package/dist/lib/perfPane.js +94 -0
  112. package/dist/lib/platform.js +312 -0
  113. package/dist/lib/precisionWheel.js +25 -0
  114. package/dist/lib/reasoning.js +39 -0
  115. package/dist/lib/rpc.js +26 -0
  116. package/dist/lib/subagentTree.js +287 -0
  117. package/dist/lib/syntax.js +89 -0
  118. package/dist/lib/terminalModes.js +46 -0
  119. package/dist/lib/terminalParity.js +48 -0
  120. package/dist/lib/terminalSetup.js +321 -0
  121. package/dist/lib/text.js +203 -0
  122. package/dist/lib/text.test.js +18 -0
  123. package/dist/lib/todo.js +2 -0
  124. package/dist/lib/todo.test.js +22 -0
  125. package/dist/lib/viewportStore.js +82 -0
  126. package/dist/lib/virtualHeights.js +61 -0
  127. package/dist/lib/wheelAccel.js +143 -0
  128. package/dist/theme.js +398 -0
  129. package/dist/types.js +1 -7
  130. package/package.json +2 -1
@@ -0,0 +1,175 @@
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 { writeFileSync } from 'node:fs';
6
+ import { evictInkCaches } from '@cvc/ink';
7
+ import { useCallback } from 'react';
8
+ import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js';
9
+ import { introMsg, toTranscriptMessages } from '../domain/messages.js';
10
+ import { ZERO } from '../domain/usage.js';
11
+ import { asRpcResult } from '../lib/rpc.js';
12
+ import { patchOverlayState } from './overlayStore.js';
13
+ import { turnController } from './turnController.js';
14
+ import { patchTurnState } from './turnStore.js';
15
+ import { getUiState, patchUiState } from './uiStore.js';
16
+ const usageFrom = (info) => (info?.usage ? { ...ZERO, ...info.usage } : ZERO);
17
+ export const writeActiveSessionFile = (sessionId, file = process.env.CVC_TUI_ACTIVE_SESSION_FILE) => {
18
+ if (!file || !sessionId) {
19
+ return;
20
+ }
21
+ try {
22
+ writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 });
23
+ }
24
+ catch {
25
+ // Best-effort shell epilogue hint only; never break live session changes.
26
+ }
27
+ };
28
+ const trimTail = (items) => {
29
+ const q = [...items];
30
+ while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
31
+ q.pop();
32
+ }
33
+ if (q.at(-1)?.role === 'user') {
34
+ q.pop();
35
+ }
36
+ return q;
37
+ };
38
+ export function useSessionLifecycle(opts) {
39
+ const { colsRef, composerActions, gw, panel, rpc, scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, setStickyPrompt, setVoiceProcessing, setVoiceRecording, sys } = opts;
40
+ const closeSession = useCallback((targetSid) => targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), [rpc]);
41
+ const resetSession = useCallback(() => {
42
+ turnController.fullReset();
43
+ setVoiceRecording(false);
44
+ setVoiceProcessing(false);
45
+ patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO });
46
+ setHistoryItems([]);
47
+ setLastUserMsg('');
48
+ setStickyPrompt('');
49
+ composerActions.setPasteSnips([]);
50
+ // Half-prune: new session has new keys, but keep a warm pool in case
51
+ // the user resumes back to the prior session.
52
+ evictInkCaches('half');
53
+ }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]);
54
+ const resetVisibleHistory = useCallback((info = null) => {
55
+ turnController.idle();
56
+ turnController.clearReasoning();
57
+ turnController.turnTools = [];
58
+ turnController.persistedToolLabels.clear();
59
+ setHistoryItems(info ? [introMsg(info)] : []);
60
+ setStickyPrompt('');
61
+ setLastUserMsg('');
62
+ composerActions.setPasteSnips([]);
63
+ patchTurnState({ activity: [] });
64
+ patchUiState({ info, usage: usageFrom(info) });
65
+ }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt]);
66
+ const newSession = useCallback(async (msg, title) => {
67
+ const setup = await rpc('setup.status', {});
68
+ if (setup?.provider_configured === false) {
69
+ panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections());
70
+ patchUiState({ status: 'setup required' });
71
+ return;
72
+ }
73
+ await closeSession(getUiState().sid);
74
+ const r = await rpc('session.create', { cols: colsRef.current });
75
+ if (!r) {
76
+ return patchUiState({ status: 'ready' });
77
+ }
78
+ const info = r.info ?? null;
79
+ const requestedTitle = title?.trim() ?? '';
80
+ resetSession();
81
+ setSessionStartedAt(Date.now());
82
+ writeActiveSessionFile(r.session_id);
83
+ patchUiState({
84
+ info,
85
+ sid: r.session_id,
86
+ status: info?.version ? 'ready' : 'starting agent…',
87
+ usage: usageFrom(info)
88
+ });
89
+ if (info) {
90
+ setHistoryItems([introMsg(info)]);
91
+ }
92
+ if (info?.credential_warning) {
93
+ sys(`warning: ${info.credential_warning}`);
94
+ }
95
+ if (info?.config_warning) {
96
+ sys(`warning: ${info.config_warning}`);
97
+ }
98
+ if (msg) {
99
+ sys(msg);
100
+ }
101
+ if (requestedTitle) {
102
+ rpc('session.title', {
103
+ session_id: r.session_id,
104
+ title: requestedTitle
105
+ })
106
+ .then(result => {
107
+ if (!result || getUiState().sid !== r.session_id) {
108
+ return;
109
+ }
110
+ const nextTitle = (result.title ?? requestedTitle).trim();
111
+ const suffix = result.pending ? ' (queued while session initializes)' : '';
112
+ sys(`session title set: ${nextTitle}${suffix}`);
113
+ })
114
+ .catch((err) => {
115
+ if (getUiState().sid !== r.session_id) {
116
+ return;
117
+ }
118
+ const message = err instanceof Error ? err.message : String(err);
119
+ sys(`warning: failed to set session title: ${message}`);
120
+ });
121
+ }
122
+ }, [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]);
123
+ const resumeById = useCallback((id) => {
124
+ patchOverlayState({ picker: false });
125
+ patchUiState({ status: 'resuming…' });
126
+ rpc('setup.status', {}).then(setup => {
127
+ if (setup?.provider_configured === false) {
128
+ panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections());
129
+ patchUiState({ status: 'setup required' });
130
+ return;
131
+ }
132
+ closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => gw
133
+ .request('session.resume', { cols: colsRef.current, session_id: id })
134
+ .then(raw => {
135
+ const r = asRpcResult(raw);
136
+ if (!r) {
137
+ sys('error: invalid response: session.resume');
138
+ return patchUiState({ status: 'ready' });
139
+ }
140
+ resetSession();
141
+ setSessionStartedAt(Date.now());
142
+ const resumed = toTranscriptMessages(r.messages);
143
+ setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed);
144
+ writeActiveSessionFile(r.resumed ?? r.session_id);
145
+ patchUiState({
146
+ info: r.info ?? null,
147
+ sid: r.session_id,
148
+ status: 'ready',
149
+ usage: usageFrom(r.info ?? null)
150
+ });
151
+ setTimeout(() => scrollRef.current?.scrollToBottom(), 0);
152
+ })
153
+ .catch((e) => {
154
+ sys(`error: ${e.message}`);
155
+ patchUiState({ status: 'ready' });
156
+ }));
157
+ });
158
+ }, [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys]);
159
+ const guardBusySessionSwitch = useCallback((what = 'switch sessions') => {
160
+ if (!getUiState().busy) {
161
+ return false;
162
+ }
163
+ sys(`interrupt the current turn before trying to ${what}`);
164
+ return true;
165
+ }, [sys]);
166
+ return {
167
+ closeSession,
168
+ guardBusySessionSwitch,
169
+ newSession,
170
+ resetSession,
171
+ resetVisibleHistory,
172
+ resumeById,
173
+ trimLastExchange: trimTail
174
+ };
175
+ }
@@ -0,0 +1,287 @@
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 { useCallback, useEffect, useRef } from 'react';
6
+ import { TYPING_IDLE_MS } from '../config/timing.js';
7
+ import { attachedImageNotice } from '../domain/messages.js';
8
+ import { looksLikeSlashCommand } from '../domain/slash.js';
9
+ import { asRpcResult } from '../lib/rpc.js';
10
+ import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js';
11
+ import { PASTE_SNIPPET_RE } from '../protocol/paste.js';
12
+ import { turnController } from './turnController.js';
13
+ import { getUiState, patchUiState } from './uiStore.js';
14
+ const DOUBLE_ENTER_MS = 450;
15
+ const SESSION_BUSY_RE = /session busy|waiting for model response/i;
16
+ const isSessionBusyError = (e) => e instanceof Error && SESSION_BUSY_RE.test(e.message);
17
+ const expandSnips = (snips) => {
18
+ const byLabel = new Map();
19
+ for (const { label, text } of snips) {
20
+ const hit = byLabel.get(label);
21
+ hit ? hit.push(text) : byLabel.set(label, [text]);
22
+ }
23
+ return (value) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok);
24
+ };
25
+ const spliceMatches = (text, matches, results) => matches.reduceRight((acc, m, i) => acc.slice(0, m.index) + results[i] + acc.slice(m.index + m[0].length), text);
26
+ export function useSubmission(opts) {
27
+ const { appendMessage, composerActions, composerRefs, composerState, gw, maybeGoodVibes, setLastUserMsg, slashRef, submitRef, sys } = opts;
28
+ const lastEmptyAt = useRef(0);
29
+ const typingIdleTimer = useRef(null);
30
+ useEffect(() => {
31
+ if (typingIdleTimer.current) {
32
+ clearTimeout(typingIdleTimer.current);
33
+ typingIdleTimer.current = null;
34
+ }
35
+ if (!composerState.input && !composerState.inputBuf.length) {
36
+ turnController.relaxStreaming();
37
+ return;
38
+ }
39
+ if (getUiState().busy) {
40
+ turnController.boostStreamingForTyping();
41
+ }
42
+ typingIdleTimer.current = setTimeout(() => {
43
+ typingIdleTimer.current = null;
44
+ turnController.relaxStreaming();
45
+ }, TYPING_IDLE_MS);
46
+ return () => {
47
+ if (typingIdleTimer.current) {
48
+ clearTimeout(typingIdleTimer.current);
49
+ typingIdleTimer.current = null;
50
+ }
51
+ };
52
+ }, [composerState.input, composerState.inputBuf]);
53
+ const send = useCallback((text, showUserMessage = true) => {
54
+ const expand = expandSnips(composerState.pasteSnips);
55
+ const startSubmit = (displayText, submitText, showUserMessage = true) => {
56
+ const sid = getUiState().sid;
57
+ if (!sid) {
58
+ return sys('session not ready yet');
59
+ }
60
+ turnController.clearStatusTimer();
61
+ maybeGoodVibes(submitText);
62
+ setLastUserMsg(text);
63
+ if (showUserMessage) {
64
+ appendMessage({ role: 'user', text: displayText });
65
+ }
66
+ patchUiState({ busy: true, status: 'running…' });
67
+ turnController.bufRef = '';
68
+ turnController.interrupted = false;
69
+ gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e) => {
70
+ if (isSessionBusyError(e)) {
71
+ composerActions.enqueue(submitText);
72
+ patchUiState({ busy: true, status: 'queued for next turn' });
73
+ return sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`);
74
+ }
75
+ sys(`error: ${e.message}`);
76
+ patchUiState({ busy: false, status: 'ready' });
77
+ });
78
+ };
79
+ const sid = getUiState().sid;
80
+ if (!sid) {
81
+ return sys('session not ready yet');
82
+ }
83
+ // Always ask the backend whether this looks like a file drop.
84
+ // The backend's _detect_file_drop handles paths with spaces, quotes,
85
+ // Windows drive letters, and escaped characters correctly.
86
+ gw.request('input.detect_drop', { session_id: sid, text })
87
+ .then(r => {
88
+ if (!r?.matched) {
89
+ return startSubmit(text, expand(text), showUserMessage);
90
+ }
91
+ if (r.is_image) {
92
+ turnController.pushActivity(attachedImageNotice(r));
93
+ }
94
+ else {
95
+ turnController.pushActivity(`detected file: ${r.name}`);
96
+ }
97
+ startSubmit(r.text || text, expand(r.text || text), showUserMessage);
98
+ })
99
+ .catch(() => startSubmit(text, expand(text), showUserMessage));
100
+ }, [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]);
101
+ const shellExec = useCallback((cmd) => {
102
+ appendMessage({ role: 'user', text: `!${cmd}` });
103
+ patchUiState({ busy: true, status: 'running…' });
104
+ gw.request('shell.exec', { command: cmd })
105
+ .then(raw => {
106
+ const r = asRpcResult(raw);
107
+ if (!r) {
108
+ return sys('error: invalid response: shell.exec');
109
+ }
110
+ const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
111
+ if (out) {
112
+ sys(out);
113
+ }
114
+ if (r.code !== 0 || !out) {
115
+ sys(`exit ${r.code}`);
116
+ }
117
+ })
118
+ .catch((e) => sys(`error: ${e.message}`))
119
+ .finally(() => patchUiState({ busy: false, status: 'ready' }));
120
+ }, [appendMessage, gw, sys]);
121
+ const interpolate = useCallback((text, then) => {
122
+ patchUiState({ status: 'interpolating…' });
123
+ const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))];
124
+ Promise.all(matches.map(m => gw
125
+ .request('shell.exec', { command: m[1] })
126
+ .then(raw => {
127
+ const r = asRpcResult(raw);
128
+ return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim();
129
+ })
130
+ .catch(() => '(error)'))).then(results => then(spliceMatches(text, matches, results)));
131
+ }, [gw]);
132
+ const sendQueued = useCallback((text) => {
133
+ if (text.startsWith('!')) {
134
+ return shellExec(text.slice(1).trim());
135
+ }
136
+ if (hasInterpolation(text)) {
137
+ patchUiState({ busy: true });
138
+ return interpolate(text, send);
139
+ }
140
+ send(text);
141
+ }, [interpolate, send, shellExec]);
142
+ // Honors `display.busy_input_mode` from config.yaml (CLI parity):
143
+ // - 'queue' (legacy): append to queueRef; drains on busy → false
144
+ // - 'steer' : inject into the current turn via session.steer; falls
145
+ // back to queue when steer is rejected (no agent / no
146
+ // tool window).
147
+ // - 'interrupt' (default): cancel the in-flight turn, then send the
148
+ // new text as a fresh prompt so it actually moves.
149
+ //
150
+ // `opts.fallbackToFront` controls whether a steer fallback re-inserts
151
+ // at the front of the queue (used by the queue-edit path to preserve
152
+ // a picked item's position); the mainline submit path always appends.
153
+ const handleBusyInput = useCallback((full, opts = {}) => {
154
+ const live = getUiState();
155
+ const mode = live.busyInputMode;
156
+ const fallback = (note) => {
157
+ if (opts.fallbackToFront) {
158
+ composerRefs.queueRef.current.unshift(full);
159
+ composerActions.syncQueue();
160
+ }
161
+ else {
162
+ composerActions.enqueue(full);
163
+ }
164
+ sys(note);
165
+ };
166
+ if (mode === 'queue') {
167
+ return composerActions.enqueue(full);
168
+ }
169
+ if (mode === 'steer' && live.sid) {
170
+ gw.request('session.steer', { session_id: live.sid, text: full })
171
+ .then(raw => {
172
+ const r = asRpcResult(raw);
173
+ if (r?.status !== 'queued') {
174
+ fallback('steer rejected — message queued for next turn');
175
+ }
176
+ })
177
+ .catch(() => fallback('steer failed — message queued for next turn'));
178
+ return;
179
+ }
180
+ // 'interrupt' (default): tear down the current turn, then send.
181
+ // `interruptTurn` fires `session.interrupt` without awaiting; if
182
+ // the gateway is still mid-response when `prompt.submit` lands,
183
+ // `send()`'s catch path re-queues with a "queued: ..." sys note
184
+ // (`isSessionBusyError`) — so a lost race degrades to queue
185
+ // semantics, not a dropped message.
186
+ if (live.sid) {
187
+ turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys });
188
+ }
189
+ if (hasInterpolation(full)) {
190
+ patchUiState({ busy: true });
191
+ return interpolate(full, send);
192
+ }
193
+ send(full);
194
+ }, [appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]);
195
+ const dispatchSubmission = useCallback((full) => {
196
+ if (!full.trim()) {
197
+ return;
198
+ }
199
+ if (looksLikeSlashCommand(full)) {
200
+ appendMessage({ kind: 'slash', role: 'system', text: full });
201
+ composerActions.pushHistory(full);
202
+ slashRef.current(full);
203
+ composerActions.clearIn();
204
+ return;
205
+ }
206
+ if (full.startsWith('!')) {
207
+ composerActions.clearIn();
208
+ return shellExec(full.slice(1).trim());
209
+ }
210
+ const live = getUiState();
211
+ if (!live.sid) {
212
+ composerActions.pushHistory(full);
213
+ composerActions.enqueue(full);
214
+ composerActions.clearIn();
215
+ return;
216
+ }
217
+ const editIdx = composerRefs.queueEditRef.current;
218
+ composerActions.clearIn();
219
+ if (editIdx !== null) {
220
+ composerActions.replaceQueue(editIdx, full);
221
+ const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0];
222
+ composerActions.syncQueue();
223
+ composerActions.setQueueEdit(null);
224
+ if (!picked || !live.sid) {
225
+ return;
226
+ }
227
+ if (getUiState().busy) {
228
+ // 'interrupt' / 'steer' should reach the live turn instead of
229
+ // silently going back to the queue. handleBusyInput resolves
230
+ // mode-specific behavior (interrupt-and-send, steer, or queue).
231
+ if (getUiState().busyInputMode === 'queue') {
232
+ composerRefs.queueRef.current.unshift(picked);
233
+ return composerActions.syncQueue();
234
+ }
235
+ return handleBusyInput(picked, { fallbackToFront: true });
236
+ }
237
+ return sendQueued(picked);
238
+ }
239
+ composerActions.pushHistory(full);
240
+ if (getUiState().busy) {
241
+ return handleBusyInput(full);
242
+ }
243
+ if (hasInterpolation(full)) {
244
+ patchUiState({ busy: true });
245
+ return interpolate(full, send);
246
+ }
247
+ send(full);
248
+ }, [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]);
249
+ const submit = useCallback((value) => {
250
+ if (composerState.completions.length) {
251
+ const row = composerState.completions[composerState.compIdx];
252
+ if (row?.text) {
253
+ const text = value.startsWith('/') && row.text.startsWith('/') ? row.text.slice(1) : row.text;
254
+ const next = value.slice(0, composerState.compReplace) + text;
255
+ if (next !== value) {
256
+ return composerActions.setInput(next);
257
+ }
258
+ }
259
+ }
260
+ if (!value.trim() && !composerState.inputBuf.length) {
261
+ const live = getUiState();
262
+ const now = Date.now();
263
+ const doubleTap = now - lastEmptyAt.current < DOUBLE_ENTER_MS;
264
+ lastEmptyAt.current = now;
265
+ if (doubleTap && live.busy && live.sid) {
266
+ return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys });
267
+ }
268
+ if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
269
+ const next = composerActions.dequeue();
270
+ composerActions.syncQueue();
271
+ if (next) {
272
+ composerActions.setQueueEdit(null);
273
+ dispatchSubmission(next);
274
+ }
275
+ }
276
+ return;
277
+ }
278
+ lastEmptyAt.current = 0;
279
+ if (value.endsWith('\\')) {
280
+ composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]);
281
+ return composerActions.setInput('');
282
+ }
283
+ dispatchSubmission([...composerState.inputBuf, value].join('\n'));
284
+ }, [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys]);
285
+ submitRef.current = submit;
286
+ return { dispatchSubmission, send, sendQueued, submit };
287
+ }