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.
- package/dist/app/completion.js +4 -0
- package/dist/app/createGatewayEventHandler.js +508 -0
- package/dist/app/createSlashHandler.js +101 -0
- package/dist/app/delegationStore.js +51 -0
- package/dist/app/gatewayContext.js +17 -0
- package/dist/app/historyStore.js +4 -0
- package/dist/app/inputBuffer.js +4 -0
- package/dist/app/inputSelectionStore.js +8 -0
- package/dist/app/inputStore.js +4 -0
- package/dist/app/interfaces.js +6 -0
- package/dist/app/overlayStore.js +40 -0
- package/dist/app/promptStore.js +4 -0
- package/dist/app/queueStore.js +4 -0
- package/dist/app/scroll.js +44 -0
- package/dist/app/setupHandoff.js +28 -0
- package/dist/app/slash/commands/core.js +421 -234
- package/dist/app/slash/commands/debug.js +39 -6
- package/dist/app/slash/commands/ops.js +471 -136
- package/dist/app/slash/commands/session.js +405 -65
- package/dist/app/slash/commands/setup.js +16 -43
- package/dist/app/slash/commands/toggles.js +4 -0
- package/dist/app/slash/registry.js +7 -68
- package/dist/app/slash/types.js +1 -16
- package/dist/app/spawnHistoryStore.js +105 -0
- package/dist/app/turnController.js +650 -0
- package/dist/app/turnStore.js +47 -59
- package/dist/app/uiStore.js +34 -29
- package/dist/app/useComposerState.js +265 -0
- package/dist/app/useConfigSync.js +144 -0
- package/dist/app/useInputHandlers.js +403 -0
- package/dist/app/useLongRunToolCharms.js +50 -0
- package/dist/app/useMainApp.js +629 -0
- package/dist/app/useSessionLifecycle.js +175 -0
- package/dist/app/useSubmission.js +287 -0
- package/dist/app.js +13 -217
- package/dist/banner.js +40 -3
- package/dist/components/agentsOverlay.js +474 -0
- package/dist/components/appChrome.js +252 -0
- package/dist/components/appLayout.js +121 -22
- package/dist/components/appOverlays.js +65 -0
- package/dist/components/branding.js +97 -6
- package/dist/components/fpsOverlay.js +22 -0
- package/dist/components/helpHint.js +21 -0
- package/dist/components/markdown.js +501 -0
- package/dist/components/maskedPrompt.js +12 -0
- package/dist/components/messageLine.js +82 -0
- package/dist/components/modelPicker.js +254 -0
- package/dist/components/overlayControls.js +30 -0
- package/dist/components/overlays/helpOverlay.js +1 -0
- package/dist/components/overlays/historySearch.js +1 -0
- package/dist/components/overlays/modelPicker.js +2 -1
- package/dist/components/overlays/overlayUtils.js +2 -1
- package/dist/components/overlays/secretPrompt.js +1 -0
- package/dist/components/overlays/sessionPicker.js +1 -0
- package/dist/components/overlays/skillsHub.js +1 -0
- package/dist/components/prompts.js +95 -0
- package/dist/components/queuedMessages.js +24 -0
- package/dist/components/sessionPicker.js +130 -0
- package/dist/components/skillsHub.js +165 -0
- package/dist/components/streamingAssistant.js +35 -0
- package/dist/components/streamingMarkdown.js +110 -186
- package/dist/components/textInput.js +748 -218
- package/dist/components/themed.js +12 -0
- package/dist/components/thinking.js +493 -36
- package/dist/components/todoPanel.js +40 -0
- package/dist/config/env.js +18 -0
- package/dist/config/limits.js +22 -0
- package/dist/config/timing.js +4 -0
- package/dist/content/charms.js +5 -0
- package/dist/content/faces.js +21 -0
- package/dist/content/fortunes.js +29 -0
- package/dist/content/hotkeys.js +38 -0
- package/dist/content/placeholders.js +15 -0
- package/dist/content/setup.js +14 -0
- package/dist/content/verbs.js +41 -0
- package/dist/domain/details.js +53 -0
- package/dist/domain/messages.js +63 -0
- package/dist/domain/paths.js +16 -0
- package/dist/domain/providers.js +11 -0
- package/dist/domain/roles.js +6 -0
- package/dist/domain/slash.js +11 -0
- package/dist/domain/usage.js +1 -0
- package/dist/domain/viewport.js +33 -0
- package/dist/entry.js +65 -40
- package/dist/gatewayClient.js +574 -0
- package/dist/gatewayTypes.js +1 -0
- package/dist/hooks/useCompletion.js +86 -0
- package/dist/hooks/useGitBranch.js +58 -0
- package/dist/hooks/useInputHistory.js +12 -0
- package/dist/hooks/useQueue.js +57 -0
- package/dist/hooks/useVirtualHistory.js +401 -0
- package/dist/lib/circularBuffer.js +43 -0
- package/dist/lib/clipboard.js +126 -0
- package/dist/lib/editor.js +41 -0
- package/dist/lib/editor.test.js +58 -0
- package/dist/lib/emoji.js +49 -0
- package/dist/lib/externalCli.js +11 -0
- package/dist/lib/forceTruecolor.js +26 -0
- package/dist/lib/fpsStore.js +36 -0
- package/dist/lib/gracefulExit.js +29 -0
- package/dist/lib/history.js +69 -0
- package/dist/lib/inputMetrics.js +143 -0
- package/dist/lib/liveProgress.js +51 -0
- package/dist/lib/liveProgress.test.js +89 -0
- package/dist/lib/mathUnicode.js +685 -0
- package/dist/lib/memory.js +123 -0
- package/dist/lib/memoryMonitor.js +76 -0
- package/dist/lib/messages.js +3 -0
- package/dist/lib/messages.test.js +25 -0
- package/dist/lib/osc52.js +53 -0
- package/dist/lib/perfPane.js +94 -0
- package/dist/lib/platform.js +312 -0
- package/dist/lib/precisionWheel.js +25 -0
- package/dist/lib/reasoning.js +39 -0
- package/dist/lib/rpc.js +26 -0
- package/dist/lib/subagentTree.js +287 -0
- package/dist/lib/syntax.js +89 -0
- package/dist/lib/terminalModes.js +46 -0
- package/dist/lib/terminalParity.js +48 -0
- package/dist/lib/terminalSetup.js +321 -0
- package/dist/lib/text.js +203 -0
- package/dist/lib/text.test.js +18 -0
- package/dist/lib/todo.js +2 -0
- package/dist/lib/todo.test.js +22 -0
- package/dist/lib/viewportStore.js +82 -0
- package/dist/lib/virtualHeights.js +61 -0
- package/dist/lib/wheelAccel.js +143 -0
- package/dist/theme.js +398 -0
- package/dist/types.js +1 -7
- 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
|
+
}
|