@pentoshi/clai 1.2.9 → 2.0.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/README.md +46 -2
- package/dist/agent/context-manager.d.ts +14 -0
- package/dist/agent/context-manager.js +61 -0
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/runner.d.ts +6 -0
- package/dist/agent/runner.js +24 -3
- package/dist/agent/runner.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/index.js +3 -17
- package/dist/index.js.map +1 -1
- package/dist/modes/agent.d.ts +4 -0
- package/dist/modes/agent.js.map +1 -1
- package/dist/repl.d.ts +5 -0
- package/dist/repl.js +8 -2
- package/dist/repl.js.map +1 -1
- package/dist/store/history.d.ts +7 -1
- package/dist/store/history.js +34 -3
- package/dist/store/history.js.map +1 -1
- package/dist/tools/registry.d.ts +4 -0
- package/dist/tools/registry.js +57 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shell.d.ts +2 -0
- package/dist/tools/shell.js +5 -1
- package/dist/tools/shell.js.map +1 -1
- package/dist/tui/App.d.ts +2 -1
- package/dist/tui/App.js +695 -100
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/components/ConfirmModal.js +2 -2
- package/dist/tui/components/ConfirmModal.js.map +1 -1
- package/dist/tui/components/Pager.d.ts +6 -5
- package/dist/tui/components/Pager.js +17 -26
- package/dist/tui/components/Pager.js.map +1 -1
- package/dist/tui/components/PickerPanel.d.ts +13 -0
- package/dist/tui/components/PickerPanel.js +68 -0
- package/dist/tui/components/PickerPanel.js.map +1 -0
- package/dist/tui/components/SecretInputPanel.d.ts +6 -0
- package/dist/tui/components/SecretInputPanel.js +25 -0
- package/dist/tui/components/SecretInputPanel.js.map +1 -0
- package/dist/tui/default.d.ts +6 -0
- package/dist/tui/default.js +11 -0
- package/dist/tui/default.js.map +1 -0
- package/dist/tui/hooks/useAgentRunner.d.ts +13 -2
- package/dist/tui/hooks/useAgentRunner.js +48 -3
- package/dist/tui/hooks/useAgentRunner.js.map +1 -1
- package/dist/tui/hooks/useTerminalSize.d.ts +9 -0
- package/dist/tui/hooks/useTerminalSize.js +26 -0
- package/dist/tui/hooks/useTerminalSize.js.map +1 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/render-lines.d.ts +15 -0
- package/dist/tui/render-lines.js +165 -0
- package/dist/tui/render-lines.js.map +1 -0
- package/dist/tui/state.d.ts +12 -10
- package/dist/tui/state.js +83 -57
- package/dist/tui/state.js.map +1 -1
- package/package.json +1 -1
- package/dist/tui/components/Composer.d.ts +0 -6
- package/dist/tui/components/Composer.js +0 -122
- package/dist/tui/components/Composer.js.map +0 -1
- package/dist/tui/components/Header.d.ts +0 -7
- package/dist/tui/components/Header.js +0 -6
- package/dist/tui/components/Header.js.map +0 -1
- package/dist/tui/components/StatusLine.d.ts +0 -7
- package/dist/tui/components/StatusLine.js +0 -25
- package/dist/tui/components/StatusLine.js.map +0 -1
- package/dist/tui/components/items.d.ts +0 -5
- package/dist/tui/components/items.js +0 -57
- package/dist/tui/components/items.js.map +0 -1
package/dist/tui/App.js
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box,
|
|
2
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
4
|
+
import { providerIds } from "../types.js";
|
|
4
5
|
import { assertProvider } from "../llm/provider.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { getProvider } from "../llm/router.js";
|
|
7
|
+
import { envValue, getProviderSecret, setProviderSecret } from "../store/keys.js";
|
|
8
|
+
import { modelSupportsThinking, modelSupportsVision } from "../llm/capabilities.js";
|
|
9
|
+
import { getConfig, getProviderModel, setDefaultMode, setDefaultProvider, setProviderModel, setThinking, updateConfig, } from "../store/config.js";
|
|
10
|
+
import { estimateMessagesTokens } from "../agent/context-manager.js";
|
|
11
|
+
import { clearAllHistory, getSession, listSessions, saveSession } from "../store/history.js";
|
|
7
12
|
import { safeCwd } from "../os/cwd.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { expandMentions, findFileSuggestions, getMentionQuery, loadImageAttachments } from "../ui/mentions.js";
|
|
17
|
+
import { deletePlan, loadPlan, savePlan } from "../store/plan.js";
|
|
10
18
|
import { renderPlanDocument } from "../ui/plan-pane.js";
|
|
11
|
-
import {
|
|
19
|
+
import { getSlashCommandSuggestions, isKnownSlashCommand, knownModels, slashCommands } from "../repl.js";
|
|
20
|
+
import { initialState, reducer, serializeTranscriptForCompaction } from "./state.js";
|
|
21
|
+
import { renderTranscriptLines } from "./render-lines.js";
|
|
12
22
|
import { createTuiConfirmPort } from "./confirm.js";
|
|
13
23
|
import { useAgentRunner } from "./hooks/useAgentRunner.js";
|
|
14
24
|
import { useJobs } from "./hooks/useJobs.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { StatusLine } from "./components/StatusLine.js";
|
|
18
|
-
import { Composer } from "./components/Composer.js";
|
|
25
|
+
import { useSpinner } from "./hooks/useSpinner.js";
|
|
26
|
+
import { useTerminalSize } from "./hooks/useTerminalSize.js";
|
|
19
27
|
import { ConfirmModal } from "./components/ConfirmModal.js";
|
|
20
28
|
import { Pager } from "./components/Pager.js";
|
|
21
29
|
import { JobsPanel } from "./components/JobsPanel.js";
|
|
30
|
+
import { PickerPanel } from "./components/PickerPanel.js";
|
|
31
|
+
import { SecretInputPanel } from "./components/SecretInputPanel.js";
|
|
32
|
+
import { clearArtifacts, clearAuditLogs } from "../store/logs.js";
|
|
33
|
+
import { addScopeTargets, clearScope, loadScope, saveScope } from "../store/scope.js";
|
|
22
34
|
const IMPLEMENT_PROMPT = "I approve the plan. Execute it now in STRICT ORDER. Task 1 (explore) is ALREADY COMPLETE from the planning phase — " +
|
|
23
35
|
"do NOT re-list or re-read the directory. Start with the FIRST pending task that still needs implementation work. " +
|
|
24
36
|
"For each task: call task.update {taskId, state:'in_progress'} → do the real work → VERIFY it succeeded → " +
|
|
@@ -27,47 +39,79 @@ const IMPLEMENT_PROMPT = "I approve the plan. Execute it now in STRICT ORDER. Ta
|
|
|
27
39
|
"Build the project for real with fs.writeMany (create all files in as few calls as possible). " +
|
|
28
40
|
"Do NOT call web.search — you already know everything needed. " +
|
|
29
41
|
"Run real commands (installs, servers, verification) — do not claim anything ran without a successful tool call.";
|
|
30
|
-
|
|
42
|
+
const MAX_SUGGESTIONS = 6;
|
|
43
|
+
export function App({ version, initialMode, provider: initialProvider, initialModel, noHistory = false }) {
|
|
31
44
|
const { exit } = useApp();
|
|
32
|
-
const {
|
|
45
|
+
const { columns: cols, rows } = useTerminalSize();
|
|
33
46
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
|
34
47
|
const [mode, setMode] = useState(initialMode);
|
|
35
48
|
const [provider, setProvider] = useState(initialProvider);
|
|
36
49
|
const [model, setModel] = useState(initialModel);
|
|
37
50
|
const [overlay, setOverlay] = useState({ kind: "none" });
|
|
51
|
+
const [input, setInput] = useState("");
|
|
52
|
+
const [cursor, setCursor] = useState(0);
|
|
53
|
+
const [selected, setSelected] = useState(0);
|
|
54
|
+
const [secretRequest, setSecretRequest] = useState();
|
|
55
|
+
const secretResolver = useRef();
|
|
56
|
+
const [scroll, setScroll] = useState(0); // lines scrolled up from bottom
|
|
57
|
+
const [compacting, setCompacting] = useState(false);
|
|
58
|
+
const history = useRef([]);
|
|
59
|
+
const historyIdx = useRef(-1);
|
|
60
|
+
const historyDraft = useRef("");
|
|
38
61
|
const lastCtrlC = useRef(0);
|
|
39
62
|
const jobs = useJobs(overlay.kind === "jobs");
|
|
40
|
-
|
|
63
|
+
const spinner = useSpinner(state.status.running);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!process.stdout.isTTY)
|
|
66
|
+
return;
|
|
67
|
+
process.stdout.write("\x1b[?1049h");
|
|
68
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
69
|
+
return () => {
|
|
70
|
+
process.stdout.write("\x1b[?1049l");
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
// ── Confirm port → in-app modal ────────────────────────────────────────────
|
|
41
74
|
const confirmController = useMemo(() => createTuiConfirmPort(), []);
|
|
42
75
|
const confirmResolver = useRef(undefined);
|
|
43
76
|
useEffect(() => {
|
|
44
|
-
confirmController.setHandler((req) => new Promise((
|
|
45
|
-
confirmResolver.current =
|
|
77
|
+
confirmController.setHandler((req) => new Promise((res) => {
|
|
78
|
+
confirmResolver.current = res;
|
|
46
79
|
dispatch({ type: "event", event: { type: "confirm-request", id: "c", ...req } });
|
|
47
80
|
}));
|
|
48
81
|
}, [confirmController]);
|
|
49
82
|
const ctxRef = useRef({ mode, provider, model });
|
|
50
83
|
ctxRef.current = { mode, provider, model };
|
|
84
|
+
const requestSecret = useCallback((request) => new Promise((resolveSecret) => {
|
|
85
|
+
secretResolver.current = resolveSecret;
|
|
86
|
+
setSecretRequest(request);
|
|
87
|
+
}), []);
|
|
51
88
|
const runner = useAgentRunner({
|
|
52
89
|
dispatchEvent: (event) => dispatch({ type: "event", event }),
|
|
53
90
|
confirm: confirmController.port,
|
|
54
91
|
getContext: useCallback(() => ctxRef.current, []),
|
|
92
|
+
requestSecret,
|
|
55
93
|
});
|
|
94
|
+
const exitTui = useCallback(() => {
|
|
95
|
+
void (async () => {
|
|
96
|
+
const messages = runner.getMessages();
|
|
97
|
+
if (!noHistory && !getConfig().privateMode && messages.length > 0) {
|
|
98
|
+
await saveSession(messages, undefined, state.items).catch(() => undefined);
|
|
99
|
+
}
|
|
100
|
+
exit();
|
|
101
|
+
})();
|
|
102
|
+
}, [exit, noHistory, runner, state.items]);
|
|
56
103
|
const startTurn = useCallback(async (display, modelInput, images) => {
|
|
104
|
+
setScroll(0);
|
|
57
105
|
dispatch({ type: "submit", text: display });
|
|
58
106
|
await runner.run(modelInput, images && images.length > 0 ? { images } : undefined);
|
|
59
107
|
}, [runner]);
|
|
60
|
-
// Expand @-mentions/dropped paths, then run the turn.
|
|
61
108
|
const beginTurn = useCallback((rawText) => {
|
|
62
109
|
const vision = modelSupportsVision(provider, model);
|
|
63
110
|
const expansion = expandMentions(rawText, safeCwd(), vision);
|
|
64
111
|
const images = vision ? loadImageAttachments(rawText, safeCwd()) : [];
|
|
65
|
-
const modelInput = expansion.contextBlock
|
|
66
|
-
? `${rawText}\n\n${expansion.contextBlock}`
|
|
67
|
-
: rawText;
|
|
112
|
+
const modelInput = expansion.contextBlock ? `${rawText}\n\n${expansion.contextBlock}` : rawText;
|
|
68
113
|
void startTurn(rawText, modelInput, images);
|
|
69
114
|
}, [provider, model, startTurn]);
|
|
70
|
-
// Flush the next queued message once the current turn finishes.
|
|
71
115
|
useEffect(() => {
|
|
72
116
|
if (!state.status.running && state.queued.length > 0 && !runner.isRunning()) {
|
|
73
117
|
const next = state.queued[0];
|
|
@@ -75,15 +119,26 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
|
|
|
75
119
|
beginTurn(next);
|
|
76
120
|
}
|
|
77
121
|
}, [state.status.running, state.queued, runner, beginTurn]);
|
|
122
|
+
const lastToolOutput = useCallback(() => {
|
|
123
|
+
for (let i = state.items.length - 1; i >= 0; i -= 1) {
|
|
124
|
+
const item = state.items[i];
|
|
125
|
+
if (item.kind === "tool" && item.output)
|
|
126
|
+
return item;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}, [state.items]);
|
|
130
|
+
const openToolOutput = useCallback(async (item) => {
|
|
131
|
+
let body = item.output;
|
|
132
|
+
if (item.artifactPath) {
|
|
133
|
+
body = await readFile(item.artifactPath, "utf8").catch(() => item.output);
|
|
134
|
+
}
|
|
135
|
+
setOverlay({ kind: "pager", title: `${item.name} · full output`, body });
|
|
136
|
+
}, []);
|
|
78
137
|
const runImplement = useCallback(async () => {
|
|
79
138
|
const session = runner.getSession();
|
|
80
139
|
const plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
81
140
|
if (!plan) {
|
|
82
|
-
dispatch({
|
|
83
|
-
type: "notice",
|
|
84
|
-
level: "info",
|
|
85
|
-
text: "no plan to implement — ask clai to plan a multi-step task first",
|
|
86
|
-
});
|
|
141
|
+
dispatch({ type: "notice", level: "info", text: "no plan to implement — ask clai to plan a multi-step task first" });
|
|
87
142
|
return;
|
|
88
143
|
}
|
|
89
144
|
if (plan.tasks.every((t) => t.state === "done")) {
|
|
@@ -96,178 +151,718 @@ export function App({ version, initialMode, provider: initialProvider, initialMo
|
|
|
96
151
|
dispatch({ type: "notice", level: "info", text: "✦ plan approved — executing it now" });
|
|
97
152
|
void startTurn("/implement", IMPLEMENT_PROMPT);
|
|
98
153
|
}, [runner, startTurn]);
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
154
|
+
const chooseModel = useCallback(() => {
|
|
155
|
+
const models = knownModels[provider] ?? [model];
|
|
156
|
+
setOverlay({
|
|
157
|
+
kind: "picker",
|
|
158
|
+
title: `Models · ${provider}`,
|
|
159
|
+
options: models.map((value) => ({ value, label: value, active: value === model })),
|
|
160
|
+
onSelect: (value) => {
|
|
161
|
+
setModel(value);
|
|
162
|
+
setProviderModel(provider, value);
|
|
163
|
+
setOverlay({ kind: "none" });
|
|
164
|
+
dispatch({ type: "notice", level: "info", text: `model → ${value}` });
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}, [model, provider]);
|
|
168
|
+
const activateProvider = useCallback(async (next) => {
|
|
169
|
+
const configured = next === "ollama" || Boolean(envValue(next)) || Boolean((await getProviderSecret(next)).value);
|
|
170
|
+
if (!configured) {
|
|
171
|
+
const key = await requestSecret({
|
|
172
|
+
title: `${next} API key`,
|
|
173
|
+
prompt: `No API key is configured for ${next}. Enter it now to activate this provider.`,
|
|
174
|
+
});
|
|
175
|
+
if (!key) {
|
|
176
|
+
dispatch({ type: "notice", level: "warn", text: `provider unchanged · ${next} needs an API key` });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!getProvider(next).validateKey(key)) {
|
|
180
|
+
dispatch({ type: "notice", level: "warn", text: `invalid API key format for ${next}` });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
await setProviderSecret(next, key);
|
|
104
184
|
}
|
|
105
|
-
|
|
106
|
-
|
|
185
|
+
const nextModel = getProviderModel(next);
|
|
186
|
+
setDefaultProvider(next);
|
|
187
|
+
setProvider(next);
|
|
188
|
+
setModel(nextModel);
|
|
189
|
+
setOverlay({ kind: "none" });
|
|
190
|
+
dispatch({ type: "notice", level: "info", text: `provider → ${next} · model → ${nextModel}` });
|
|
191
|
+
}, [requestSecret]);
|
|
192
|
+
const chooseProvider = useCallback(() => {
|
|
193
|
+
setOverlay({
|
|
194
|
+
kind: "picker",
|
|
195
|
+
title: "Providers",
|
|
196
|
+
options: providerIds.map((value) => ({
|
|
197
|
+
value,
|
|
198
|
+
label: value,
|
|
199
|
+
description: getProviderModel(value),
|
|
200
|
+
active: value === provider,
|
|
201
|
+
})),
|
|
202
|
+
onSelect: (value) => {
|
|
203
|
+
const next = assertProvider(value);
|
|
204
|
+
void activateProvider(next);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}, [activateProvider, provider]);
|
|
208
|
+
const setReasoning = useCallback((value) => {
|
|
209
|
+
if (value === "off" || value === "none")
|
|
210
|
+
setThinking({ enabled: false });
|
|
211
|
+
else
|
|
212
|
+
setThinking({ enabled: true, effort: value });
|
|
213
|
+
dispatch({
|
|
214
|
+
type: "notice",
|
|
215
|
+
level: "info",
|
|
216
|
+
text: value === "off" || value === "none" ? "thinking → off" : `thinking → ${value}`,
|
|
217
|
+
});
|
|
218
|
+
}, []);
|
|
219
|
+
const chooseReasoning = useCallback(() => {
|
|
220
|
+
const current = getConfig().thinking;
|
|
221
|
+
const descriptions = {
|
|
222
|
+
off: "disable reasoning",
|
|
223
|
+
minimal: "lowest latency",
|
|
224
|
+
low: "light reasoning",
|
|
225
|
+
medium: "balanced",
|
|
226
|
+
high: "deep reasoning",
|
|
227
|
+
xhigh: "maximum depth",
|
|
228
|
+
};
|
|
229
|
+
setOverlay({
|
|
230
|
+
kind: "picker",
|
|
231
|
+
title: `Reasoning · ${modelSupportsThinking(provider, model) ? "supported" : "model may ignore it"}`,
|
|
232
|
+
options: Object.entries(descriptions).map(([value, description]) => ({
|
|
233
|
+
value,
|
|
234
|
+
label: value,
|
|
235
|
+
description,
|
|
236
|
+
active: value === (current.enabled ? current.effort : "off"),
|
|
237
|
+
})),
|
|
238
|
+
onSelect: (value) => {
|
|
239
|
+
setReasoning(value);
|
|
240
|
+
setOverlay({ kind: "none" });
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}, [model, provider, setReasoning]);
|
|
107
244
|
const handleLocalSlash = useCallback((text) => {
|
|
108
245
|
const [cmd, ...rest] = text.trim().split(/\s+/);
|
|
109
246
|
const arg = rest.join(" ").trim();
|
|
247
|
+
const info = (t) => dispatch({ type: "notice", level: "info", text: t });
|
|
248
|
+
const warn = (t) => dispatch({ type: "notice", level: "warn", text: t });
|
|
110
249
|
switch (cmd) {
|
|
111
250
|
case "/ask":
|
|
112
251
|
setMode("ask");
|
|
113
252
|
setDefaultMode("ask");
|
|
114
|
-
|
|
253
|
+
info("mode → ask");
|
|
115
254
|
return true;
|
|
116
255
|
case "/agent":
|
|
117
256
|
setMode("agent");
|
|
118
257
|
setDefaultMode("agent");
|
|
119
|
-
|
|
258
|
+
info("mode → agent");
|
|
120
259
|
return true;
|
|
121
260
|
case "/clear":
|
|
261
|
+
runner.reset();
|
|
262
|
+
dispatch({ type: "reset" });
|
|
263
|
+
info("context cleared");
|
|
264
|
+
return true;
|
|
122
265
|
case "/new":
|
|
266
|
+
void (async () => {
|
|
267
|
+
const messages = runner.getMessages();
|
|
268
|
+
if (!noHistory && !getConfig().privateMode && messages.length > 0)
|
|
269
|
+
await saveSession(messages, undefined, state.items).catch(() => undefined);
|
|
270
|
+
runner.reset();
|
|
271
|
+
dispatch({ type: "reset" });
|
|
272
|
+
info("fresh session started");
|
|
273
|
+
})();
|
|
274
|
+
return true;
|
|
275
|
+
case "/clean":
|
|
123
276
|
runner.reset();
|
|
124
277
|
dispatch({ type: "reset" });
|
|
278
|
+
info("fresh session started");
|
|
125
279
|
return true;
|
|
126
280
|
case "/think":
|
|
127
281
|
case "/thinking":
|
|
128
282
|
dispatch({ type: "toggle-thinking" });
|
|
129
283
|
return true;
|
|
130
284
|
case "/implement":
|
|
131
|
-
if (runner.isRunning())
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
else {
|
|
285
|
+
if (runner.isRunning())
|
|
286
|
+
warn("a turn is already running");
|
|
287
|
+
else
|
|
135
288
|
void runImplement();
|
|
136
|
-
}
|
|
137
289
|
return true;
|
|
138
|
-
case "/plan":
|
|
290
|
+
case "/plan":
|
|
139
291
|
void (async () => {
|
|
140
292
|
const plan = await loadPlan(runner.getSession().sessionId).catch(() => undefined);
|
|
141
|
-
if (!plan)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
setOverlay({ kind: "pager", title: "Plan", body: renderPlanDocument(plan) });
|
|
293
|
+
if (!plan)
|
|
294
|
+
info("no active plan yet");
|
|
295
|
+
else
|
|
296
|
+
setOverlay({ kind: "pager", title: "Plan", body: renderPlanDocument(plan) });
|
|
146
297
|
})();
|
|
147
298
|
return true;
|
|
148
|
-
}
|
|
149
299
|
case "/jobs":
|
|
150
300
|
setOverlay({ kind: "jobs" });
|
|
151
301
|
return true;
|
|
152
302
|
case "/output": {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
else {
|
|
158
|
-
setOverlay({ kind: "pager", title: `${last.name} output`, body: last.output });
|
|
303
|
+
const outputs = state.items.filter((item) => item.kind === "tool" && Boolean(item.output));
|
|
304
|
+
if (arg === "list" || arg === "ls") {
|
|
305
|
+
info(outputs.length ? outputs.map((item) => `${item.id} · ${item.name}`).join("\n") : "no tool output yet");
|
|
306
|
+
return true;
|
|
159
307
|
}
|
|
308
|
+
const selectedOutput = arg && arg !== "last" ? outputs.find((item) => item.id === arg) : lastToolOutput();
|
|
309
|
+
if (!selectedOutput)
|
|
310
|
+
info(arg ? `no tool output: ${arg}` : "no tool output yet");
|
|
311
|
+
else
|
|
312
|
+
void openToolOutput(selectedOutput);
|
|
160
313
|
return true;
|
|
161
314
|
}
|
|
162
|
-
case "/model":
|
|
163
|
-
if (!arg) {
|
|
164
|
-
|
|
315
|
+
case "/model":
|
|
316
|
+
if (!arg || arg === "list" || arg === "ls") {
|
|
317
|
+
chooseModel();
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
{
|
|
321
|
+
const options = knownModels[provider] ?? [];
|
|
322
|
+
const index = Number.parseInt(arg, 10);
|
|
323
|
+
const nextModel = Number.isInteger(index) && index >= 1 && index <= options.length ? options[index - 1] : arg;
|
|
324
|
+
setModel(nextModel);
|
|
325
|
+
setProviderModel(provider, nextModel);
|
|
326
|
+
info(`model → ${nextModel}`);
|
|
165
327
|
return true;
|
|
166
328
|
}
|
|
167
|
-
setModel(arg);
|
|
168
|
-
setProviderModel(provider, arg);
|
|
169
|
-
dispatch({ type: "notice", level: "info", text: `model → ${arg}` });
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
329
|
case "/provider":
|
|
173
|
-
case "/use":
|
|
330
|
+
case "/use":
|
|
174
331
|
if (!arg) {
|
|
175
|
-
|
|
332
|
+
chooseProvider();
|
|
176
333
|
return true;
|
|
177
334
|
}
|
|
178
335
|
try {
|
|
179
336
|
const next = assertProvider(arg);
|
|
180
|
-
|
|
181
|
-
const nextModel = getProviderModel(next);
|
|
182
|
-
setModel(nextModel);
|
|
183
|
-
dispatch({ type: "notice", level: "info", text: `provider → ${next} / ${nextModel}` });
|
|
337
|
+
void activateProvider(next);
|
|
184
338
|
}
|
|
185
339
|
catch {
|
|
186
|
-
|
|
340
|
+
warn(`unknown provider: ${arg}`);
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
case "/variants":
|
|
344
|
+
case "/reasoning": {
|
|
345
|
+
if (!arg) {
|
|
346
|
+
chooseReasoning();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
const value = arg.toLowerCase();
|
|
350
|
+
if (/^(on|enable|true)$/.test(value)) {
|
|
351
|
+
setThinking({ enabled: true });
|
|
352
|
+
info(`thinking → ${getConfig().thinking.effort}`);
|
|
187
353
|
}
|
|
354
|
+
else if (["off", "none", "disable", "false"].includes(value)) {
|
|
355
|
+
setReasoning("off");
|
|
356
|
+
}
|
|
357
|
+
else if (["minimal", "low", "medium", "high", "xhigh"].includes(value)) {
|
|
358
|
+
setReasoning(value);
|
|
359
|
+
}
|
|
360
|
+
else
|
|
361
|
+
warn("usage: /variants [on|off|minimal|low|medium|high|xhigh]");
|
|
188
362
|
return true;
|
|
189
363
|
}
|
|
364
|
+
case "/cwd": {
|
|
365
|
+
if (!arg) {
|
|
366
|
+
info(`cwd: ${safeCwd()}`);
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
const target = resolve(safeCwd(), arg);
|
|
370
|
+
if (!existsSync(target)) {
|
|
371
|
+
warn(`no such directory: ${target}`);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
process.chdir(target);
|
|
376
|
+
info(`cwd → ${target}`);
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
warn(`could not chdir: ${e instanceof Error ? e.message : String(e)}`);
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
case "/allow":
|
|
384
|
+
if (!arg || arg === "list" || arg === "ls") {
|
|
385
|
+
const list = [...runner.getSession().allow];
|
|
386
|
+
info(list.length ? `allowed: ${list.join(", ")}` : "no session allowances");
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
runner.getSession().allow.add(arg);
|
|
390
|
+
info(`allowed for session: ${arg}`);
|
|
391
|
+
return true;
|
|
392
|
+
case "/disallow":
|
|
393
|
+
if (arg)
|
|
394
|
+
runner.getSession().allow.delete(arg);
|
|
395
|
+
info(arg ? `disallowed: ${arg}` : "usage: /disallow <tool>");
|
|
396
|
+
return true;
|
|
397
|
+
case "/context": {
|
|
398
|
+
const msgs = runner.getMessages();
|
|
399
|
+
info(`context: ${msgs.length} messages · ~${estimateMessagesTokens(msgs)} tokens`);
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
case "/compact": {
|
|
403
|
+
if (runner.isRunning() || compacting) {
|
|
404
|
+
warn("wait for the current operation to finish");
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
setCompacting(true);
|
|
408
|
+
info("compacting conversation…");
|
|
409
|
+
const fullSession = serializeTranscriptForCompaction(state.items);
|
|
410
|
+
void runner.compact(fullSession).then((result) => {
|
|
411
|
+
if (result.after === result.before) {
|
|
412
|
+
info("nothing to compact yet — more than 8 recent messages are required");
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
info(`compacted ${result.before} → ${result.after} messages · ~${result.beforeTokens.toLocaleString()} → ~${result.afterTokens.toLocaleString()} tokens${result.summarized ? "" : " · local fallback"}`);
|
|
416
|
+
}
|
|
417
|
+
}).catch((error) => warn(`compaction failed: ${error instanceof Error ? error.message : String(error)}`))
|
|
418
|
+
.finally(() => setCompacting(false));
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
case "/save":
|
|
422
|
+
void (async () => {
|
|
423
|
+
const msgs = runner.getMessages();
|
|
424
|
+
if (msgs.length === 0) {
|
|
425
|
+
info("nothing to save yet");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const rec = await saveSession(msgs, arg || undefined, state.items).catch(() => undefined);
|
|
429
|
+
info(rec ? `saved session ${rec.id}` : "save failed");
|
|
430
|
+
})();
|
|
431
|
+
return true;
|
|
432
|
+
case "/history":
|
|
433
|
+
void (async () => {
|
|
434
|
+
const sessions = await listSessions(50);
|
|
435
|
+
const currentMessages = runner.getMessages();
|
|
436
|
+
if (sessions.length === 0 && currentMessages.length === 0) {
|
|
437
|
+
info("no session history yet");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
setOverlay({
|
|
441
|
+
kind: "picker",
|
|
442
|
+
title: "Session history",
|
|
443
|
+
options: [
|
|
444
|
+
...(currentMessages.length ? [{
|
|
445
|
+
value: "__current__",
|
|
446
|
+
label: "Current session",
|
|
447
|
+
description: `${currentMessages.length} messages · active now`,
|
|
448
|
+
active: true,
|
|
449
|
+
}] : []),
|
|
450
|
+
...sessions.map((session) => ({
|
|
451
|
+
value: session.id,
|
|
452
|
+
label: session.name ?? session.id,
|
|
453
|
+
description: `${session.createdAt.slice(0, 16).replace("T", " ")} · ${session.transcript?.length ?? session.messages.length} items`,
|
|
454
|
+
})),
|
|
455
|
+
],
|
|
456
|
+
onSelect: (id) => {
|
|
457
|
+
void (async () => {
|
|
458
|
+
if (id === "__current__") {
|
|
459
|
+
setOverlay({ kind: "none" });
|
|
460
|
+
info("showing current session");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const session = await getSession(id);
|
|
464
|
+
if (!session) {
|
|
465
|
+
warn("session not found");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
runner.setMessages(session.messages);
|
|
469
|
+
setOverlay({ kind: "none" });
|
|
470
|
+
dispatch({ type: "load-history", messages: session.messages, transcript: session.transcript });
|
|
471
|
+
setScroll(0);
|
|
472
|
+
info(`session resumed · ${session.transcript?.length ?? session.messages.length} items`);
|
|
473
|
+
})();
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
})();
|
|
477
|
+
return true;
|
|
478
|
+
case "/reset":
|
|
479
|
+
void clearAllHistory().then((result) => info(`history cleared · ${result.detail || "ok"}`));
|
|
480
|
+
return true;
|
|
481
|
+
case "/discard":
|
|
482
|
+
void (async () => {
|
|
483
|
+
const session = runner.getSession();
|
|
484
|
+
const plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
485
|
+
if (!plan) {
|
|
486
|
+
info("no active plan to discard");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
await deletePlan(session.sessionId);
|
|
490
|
+
session.planApproved.value = false;
|
|
491
|
+
info(`plan discarded · ${plan.goal}`);
|
|
492
|
+
})();
|
|
493
|
+
return true;
|
|
494
|
+
case "/scope":
|
|
495
|
+
void (async () => {
|
|
496
|
+
const [sub = "show", ...parts] = arg.split(/\s+/).filter(Boolean);
|
|
497
|
+
if (["clear", "reset", "off"].includes(sub)) {
|
|
498
|
+
await clearScope();
|
|
499
|
+
info("engagement scope cleared");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (sub === "show" || sub === "list" || sub === "ls") {
|
|
503
|
+
const scope = await loadScope();
|
|
504
|
+
info(scope ? `scope: ${scope.name ?? "unnamed"} · ${scope.authorizedTargets.join(", ")}` : "no engagement scope configured");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (sub === "add") {
|
|
508
|
+
const targets = parts.join(" ").split(/[\s,]+/).filter(Boolean);
|
|
509
|
+
if (!targets.length) {
|
|
510
|
+
warn("usage: /scope add <target1,target2>");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const scope = await addScopeTargets(targets);
|
|
514
|
+
info(`scope updated · ${scope.authorizedTargets.join(", ")}`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (sub === "new" || sub === "set") {
|
|
518
|
+
const targets = parts.join(" ").split(/[\s,]+/).filter(Boolean);
|
|
519
|
+
if (!targets.length) {
|
|
520
|
+
warn("usage: /scope new <target1,target2>");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
await saveScope({ authorizedTargets: targets, createdAt: new Date().toISOString() });
|
|
524
|
+
info(`scope created · ${targets.join(", ")}`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
warn("usage: /scope [show|clear|new <targets>|add <targets>]");
|
|
528
|
+
})().catch((error) => warn(error instanceof Error ? error.message : String(error)));
|
|
529
|
+
return true;
|
|
530
|
+
case "/privacy":
|
|
531
|
+
void (async () => {
|
|
532
|
+
const sub = (arg || "status").toLowerCase();
|
|
533
|
+
if (["on", "enable"].includes(sub)) {
|
|
534
|
+
updateConfig({ privateMode: true });
|
|
535
|
+
info("private mode → on");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (["off", "disable"].includes(sub)) {
|
|
539
|
+
updateConfig({ privateMode: false });
|
|
540
|
+
info("private mode → off");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (sub === "status") {
|
|
544
|
+
info(`private mode: ${getConfig().privateMode ? "on" : "off"}`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (sub === "clear-history") {
|
|
548
|
+
const result = await clearAllHistory();
|
|
549
|
+
info(`history cleared · ${result.detail || "ok"}`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (sub === "clear-logs") {
|
|
553
|
+
const result = await clearAuditLogs();
|
|
554
|
+
info(`audit logs cleared · ${result.removed} files`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (sub === "clear-artifacts") {
|
|
558
|
+
const result = await clearArtifacts();
|
|
559
|
+
info(`artifacts cleared · ${result.removed} files`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (sub === "clear-all") {
|
|
563
|
+
const [historyResult, logResult, artifactResult] = await Promise.all([clearAllHistory(), clearAuditLogs(), clearArtifacts()]);
|
|
564
|
+
info(`cleared history (${historyResult.detail || "ok"}), logs (${logResult.removed}), artifacts (${artifactResult.removed})`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
warn("usage: /privacy [status|on|off|clear-history|clear-logs|clear-artifacts|clear-all]");
|
|
568
|
+
})();
|
|
569
|
+
return true;
|
|
570
|
+
case "/freeonly": {
|
|
571
|
+
const on = /^(on|true|1|enable)$/i.test(arg), off = /^(off|false|0|disable)$/i.test(arg);
|
|
572
|
+
if (!on && !off) {
|
|
573
|
+
info(`freeOnly=${getConfig().freeOnly}`);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
updateConfig({ freeOnly: on });
|
|
577
|
+
info(`freeOnly=${on}`);
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
case "/fallback": {
|
|
581
|
+
const on = /^(on|true|1|enable)$/i.test(arg), off = /^(off|false|0|disable)$/i.test(arg);
|
|
582
|
+
if (!on && !off) {
|
|
583
|
+
info(`providerFallback=${getConfig().providerFallback}`);
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
updateConfig({ providerFallback: on });
|
|
587
|
+
info(`providerFallback=${on}`);
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
case "/set":
|
|
591
|
+
case "/unset":
|
|
592
|
+
case "/update":
|
|
593
|
+
case "/keys":
|
|
594
|
+
info(`${cmd} manages external credentials or updates; use the equivalent \`clai ${cmd.slice(1)}\` command outside the TUI`);
|
|
595
|
+
return true;
|
|
190
596
|
case "/help":
|
|
191
|
-
|
|
192
|
-
type: "notice",
|
|
193
|
-
level: "info",
|
|
194
|
-
text: "commands: /ask /agent /model <name> /provider <name> /implement /plan /jobs /output /clear /think /exit · " +
|
|
195
|
-
"Ctrl+T thinking · Ctrl+O output · Ctrl+P plan · Ctrl+J jobs · Esc cancel · Ctrl+C exit",
|
|
196
|
-
});
|
|
597
|
+
setOverlay({ kind: "pager", title: "Commands", body: slashCommands.map((item) => `${item.command}${item.usage ? ` ${item.usage}` : ""}\n ${item.description}`).join("\n\n") });
|
|
197
598
|
return true;
|
|
198
599
|
case "/exit":
|
|
199
600
|
case "/quit":
|
|
200
|
-
|
|
601
|
+
exitTui();
|
|
201
602
|
return true;
|
|
202
|
-
default:
|
|
203
|
-
return false;
|
|
603
|
+
default: return false;
|
|
204
604
|
}
|
|
205
|
-
}, [
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return;
|
|
210
|
-
dispatch({ type: "notice", level: "warn", text: `unknown command: ${text}` });
|
|
605
|
+
}, [activateProvider, chooseModel, chooseProvider, chooseReasoning, compacting, exitTui, noHistory, provider, runner, runImplement, lastToolOutput, openToolOutput, setReasoning, state.items]);
|
|
606
|
+
const submitText = useCallback((text) => {
|
|
607
|
+
const trimmed = text.trim();
|
|
608
|
+
if (!trimmed)
|
|
211
609
|
return;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
610
|
+
history.current.push(text);
|
|
611
|
+
historyIdx.current = -1;
|
|
612
|
+
historyDraft.current = "";
|
|
613
|
+
setInput("");
|
|
614
|
+
setCursor(0);
|
|
615
|
+
setSelected(0);
|
|
616
|
+
// A macOS/Linux drag-and-drop commonly starts with an absolute path.
|
|
617
|
+
// Only route a leading slash through the command handler when its first
|
|
618
|
+
// token is an actual clai command; file paths remain normal prompts.
|
|
619
|
+
if (trimmed.startsWith("/") && isKnownSlashCommand(trimmed)) {
|
|
620
|
+
if (!handleLocalSlash(trimmed))
|
|
621
|
+
dispatch({ type: "notice", level: "warn", text: `unknown command: ${trimmed}` });
|
|
215
622
|
return;
|
|
216
623
|
}
|
|
217
|
-
|
|
624
|
+
if (runner.isRunning())
|
|
625
|
+
dispatch({ type: "queue", text: trimmed });
|
|
626
|
+
else
|
|
627
|
+
beginTurn(trimmed);
|
|
218
628
|
}, [handleLocalSlash, runner, beginTurn]);
|
|
219
629
|
const answerConfirm = useCallback((ok) => {
|
|
220
630
|
confirmResolver.current?.(ok);
|
|
221
631
|
confirmResolver.current = undefined;
|
|
222
632
|
dispatch({ type: "confirm-resolved" });
|
|
223
633
|
}, []);
|
|
634
|
+
const answerSecret = useCallback((value) => {
|
|
635
|
+
secretResolver.current?.(value);
|
|
636
|
+
secretResolver.current = undefined;
|
|
637
|
+
setSecretRequest(undefined);
|
|
638
|
+
}, []);
|
|
639
|
+
// ── Layout math (keep the composer pinned to the bottom) ────────────────────
|
|
640
|
+
const suggestions = input.startsWith("/")
|
|
641
|
+
? getSlashCommandSuggestions(input).slice(0, MAX_SUGGESTIONS)
|
|
642
|
+
: [];
|
|
643
|
+
const mention = getMentionQuery(input, cursor);
|
|
644
|
+
const fileSuggestions = mention
|
|
645
|
+
? findFileSuggestions(mention.query, safeCwd(), MAX_SUGGESTIONS)
|
|
646
|
+
: [];
|
|
647
|
+
const slashMenuOpen = suggestions.length > 0;
|
|
648
|
+
const fileMenuOpen = !slashMenuOpen && Boolean(mention) && fileSuggestions.length > 0;
|
|
649
|
+
const menuOpen = slashMenuOpen || fileMenuOpen;
|
|
224
650
|
const overlayOpen = overlay.kind !== "none";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
651
|
+
const modalActive = Boolean(state.pendingConfirm) || Boolean(secretRequest) || overlayOpen;
|
|
652
|
+
// Leave the terminal's final row unused. Painting through the last cell can
|
|
653
|
+
// trigger an implicit scroll in several terminals, which looks like a full
|
|
654
|
+
// screen flash on every keypress/spinner frame.
|
|
655
|
+
const usableRows = Math.max(8, rows - 1);
|
|
656
|
+
const headerH = 4;
|
|
657
|
+
const statusH = state.pendingConfirm ? 6 : secretRequest ? 7 : 1;
|
|
658
|
+
const composerH = 3;
|
|
659
|
+
const menuH = slashMenuOpen ? suggestions.length : fileMenuOpen ? fileSuggestions.length : 0;
|
|
660
|
+
const viewportH = Math.max(3, usableRows - headerH - statusH - composerH - menuH);
|
|
661
|
+
const transcriptLines = renderTranscriptLines(state, {
|
|
662
|
+
width: cols,
|
|
663
|
+
thinkingExpanded: state.thinkingExpanded,
|
|
664
|
+
outputExpanded: state.outputExpanded,
|
|
665
|
+
running: state.status.running,
|
|
666
|
+
});
|
|
667
|
+
const total = transcriptLines.length;
|
|
668
|
+
const maxOffset = Math.max(0, total - viewportH);
|
|
669
|
+
const offset = Math.min(scroll, maxOffset);
|
|
670
|
+
const end = total - offset;
|
|
671
|
+
const start = Math.max(0, end - viewportH);
|
|
672
|
+
let visible = transcriptLines.slice(start, end);
|
|
673
|
+
if (visible.length < viewportH) {
|
|
674
|
+
visible = [...Array(viewportH - visible.length).fill(""), ...visible];
|
|
675
|
+
}
|
|
676
|
+
// ── Key handling ────────────────────────────────────────────────────────────
|
|
677
|
+
useInput((ch, key) => {
|
|
678
|
+
if (modalActive)
|
|
679
|
+
return; // overlay/modal owns input
|
|
680
|
+
// Global shortcuts
|
|
681
|
+
if (key.ctrl && ch === "t") {
|
|
230
682
|
dispatch({ type: "toggle-thinking" });
|
|
231
683
|
return;
|
|
232
684
|
}
|
|
233
|
-
if (key.ctrl &&
|
|
685
|
+
if (key.ctrl && ch === "o") {
|
|
234
686
|
const last = lastToolOutput();
|
|
235
687
|
if (last)
|
|
236
|
-
|
|
688
|
+
void openToolOutput(last);
|
|
689
|
+
else
|
|
690
|
+
dispatch({ type: "notice", level: "info", text: "no tool output yet" });
|
|
237
691
|
return;
|
|
238
692
|
}
|
|
239
|
-
if (key.ctrl &&
|
|
693
|
+
if (key.ctrl && ch === "p") {
|
|
240
694
|
void (async () => {
|
|
241
695
|
const plan = await loadPlan(runner.getSession().sessionId).catch(() => undefined);
|
|
242
696
|
if (plan)
|
|
243
697
|
setOverlay({ kind: "pager", title: "Plan", body: renderPlanDocument(plan) });
|
|
698
|
+
else
|
|
699
|
+
dispatch({ type: "notice", level: "info", text: "no active plan yet" });
|
|
244
700
|
})();
|
|
245
701
|
return;
|
|
246
702
|
}
|
|
247
|
-
if (key.ctrl &&
|
|
703
|
+
if (key.ctrl && ch === "j") {
|
|
248
704
|
setOverlay({ kind: "jobs" });
|
|
249
705
|
return;
|
|
250
706
|
}
|
|
707
|
+
// Scrolling
|
|
708
|
+
if (key.pageUp) {
|
|
709
|
+
setScroll((s) => Math.min(maxOffset, s + viewportH));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (key.pageDown) {
|
|
713
|
+
setScroll((s) => Math.max(0, s - viewportH));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (key.ctrl && ch === "u") {
|
|
717
|
+
setScroll((s) => Math.min(maxOffset, s + Math.floor(viewportH / 2)));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (key.ctrl && ch === "d") {
|
|
721
|
+
setScroll((s) => Math.max(0, s - Math.floor(viewportH / 2)));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (!input && maxOffset > 0 && ch === "k") {
|
|
725
|
+
setScroll((s) => Math.min(maxOffset, s + 1));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (!input && maxOffset > 0 && ch === "j") {
|
|
729
|
+
setScroll((s) => Math.max(0, s - 1));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
251
732
|
if (key.escape) {
|
|
252
733
|
if (runner.isRunning())
|
|
253
734
|
runner.abort();
|
|
735
|
+
else if (input) {
|
|
736
|
+
setInput("");
|
|
737
|
+
setCursor(0);
|
|
738
|
+
setSelected(0);
|
|
739
|
+
}
|
|
254
740
|
return;
|
|
255
741
|
}
|
|
256
|
-
if (key.ctrl &&
|
|
742
|
+
if (key.ctrl && ch === "c") {
|
|
257
743
|
if (runner.isRunning()) {
|
|
258
744
|
runner.abort();
|
|
259
745
|
return;
|
|
260
746
|
}
|
|
261
747
|
const now = Date.now();
|
|
262
748
|
if (now - lastCtrlC.current < 1500)
|
|
263
|
-
|
|
749
|
+
exitTui();
|
|
264
750
|
else
|
|
265
751
|
lastCtrlC.current = now;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// Slash menu navigation
|
|
755
|
+
if (menuOpen && (key.upArrow || key.downArrow)) {
|
|
756
|
+
setSelected((s) => {
|
|
757
|
+
const n = slashMenuOpen ? suggestions.length : fileSuggestions.length;
|
|
758
|
+
return key.upArrow ? (s - 1 + n) % n : (s + 1) % n;
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (menuOpen && key.tab) {
|
|
763
|
+
if (slashMenuOpen) {
|
|
764
|
+
setInput(suggestions[selected].command + " ");
|
|
765
|
+
setCursor(suggestions[selected].command.length + 1);
|
|
766
|
+
}
|
|
767
|
+
else if (mention && fileSuggestions[selected]) {
|
|
768
|
+
const suggestion = fileSuggestions[selected];
|
|
769
|
+
const inserted = `@${suggestion.value}${suggestion.isDir ? "" : " "}`;
|
|
770
|
+
const next = input.slice(0, mention.start) + inserted + input.slice(cursor);
|
|
771
|
+
setInput(next);
|
|
772
|
+
setCursor(mention.start + inserted.length);
|
|
773
|
+
}
|
|
774
|
+
setSelected(0);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (key.return) {
|
|
778
|
+
if (slashMenuOpen) {
|
|
779
|
+
submitText(suggestions[selected].command);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (fileMenuOpen && mention && fileSuggestions[selected]) {
|
|
783
|
+
const suggestion = fileSuggestions[selected];
|
|
784
|
+
const inserted = `@${suggestion.value}${suggestion.isDir ? "" : " "}`;
|
|
785
|
+
const next = input.slice(0, mention.start) + inserted + input.slice(cursor);
|
|
786
|
+
setInput(next);
|
|
787
|
+
setCursor(mention.start + inserted.length);
|
|
788
|
+
setSelected(0);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
submitText(input);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// History (when menu closed)
|
|
795
|
+
if (!menuOpen && key.upArrow) {
|
|
796
|
+
if (history.current.length === 0)
|
|
797
|
+
return;
|
|
798
|
+
if (historyIdx.current < 0)
|
|
799
|
+
historyDraft.current = input;
|
|
800
|
+
const idx = historyIdx.current < 0 ? history.current.length - 1 : Math.max(0, historyIdx.current - 1);
|
|
801
|
+
historyIdx.current = idx;
|
|
802
|
+
const v = history.current[idx] ?? "";
|
|
803
|
+
setInput(v);
|
|
804
|
+
setCursor(v.length);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (!menuOpen && key.downArrow) {
|
|
808
|
+
if (historyIdx.current < 0)
|
|
809
|
+
return;
|
|
810
|
+
const idx = historyIdx.current + 1;
|
|
811
|
+
if (idx >= history.current.length) {
|
|
812
|
+
historyIdx.current = -1;
|
|
813
|
+
const draft = historyDraft.current;
|
|
814
|
+
setInput(draft);
|
|
815
|
+
setCursor(draft.length);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
historyIdx.current = idx;
|
|
819
|
+
const v = history.current[idx] ?? "";
|
|
820
|
+
setInput(v);
|
|
821
|
+
setCursor(v.length);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (key.leftArrow) {
|
|
825
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (key.rightArrow) {
|
|
829
|
+
setCursor((c) => Math.min(input.length, c + 1));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (key.backspace || key.delete) {
|
|
833
|
+
if (cursor === 0)
|
|
834
|
+
return;
|
|
835
|
+
setInput(input.slice(0, cursor - 1) + input.slice(cursor));
|
|
836
|
+
setCursor(cursor - 1);
|
|
837
|
+
setSelected(0);
|
|
838
|
+
historyIdx.current = -1;
|
|
839
|
+
historyDraft.current = "";
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (key.ctrl || key.meta)
|
|
843
|
+
return;
|
|
844
|
+
if (ch) {
|
|
845
|
+
setInput(input.slice(0, cursor) + ch + input.slice(cursor));
|
|
846
|
+
setCursor(cursor + ch.length);
|
|
847
|
+
setSelected(0);
|
|
848
|
+
historyIdx.current = -1;
|
|
849
|
+
historyDraft.current = "";
|
|
266
850
|
}
|
|
267
851
|
});
|
|
268
|
-
const { committed, live } = splitItems(state.items);
|
|
269
|
-
const cols = stdout?.columns ?? 80;
|
|
270
852
|
const closeOverlay = useCallback(() => setOverlay({ kind: "none" }), []);
|
|
271
|
-
|
|
853
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
854
|
+
const elapsed = state.status.startedAt
|
|
855
|
+
? Math.max(0, Math.floor((Date.now() - state.status.startedAt) / 1000))
|
|
856
|
+
: 0;
|
|
857
|
+
const before = input.slice(0, cursor);
|
|
858
|
+
const at = input.slice(cursor, cursor + 1) || " ";
|
|
859
|
+
const after = input.slice(cursor + 1);
|
|
860
|
+
return (_jsxs(Box, { flexDirection: "column", width: cols, height: usableRows, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", marginX: 2, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: "magenta", bold: true, children: "\u25C6 clai" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: mode }), _jsx(Text, { dimColor: true, children: " mode" })] })] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "green", children: provider }), _jsx(Text, { dimColor: true, children: " / " }), _jsx(Text, { color: "cyan", children: model }), _jsx(Text, { dimColor: true, children: ` · ${safeCwd()}` }), offset > 0 ? _jsx(Text, { color: "yellow", children: ` · ▲ ${offset}` }) : null] })] }), overlay.kind === "pager" ? (_jsx(Pager, { title: overlay.title, body: overlay.body, height: viewportH, onClose: closeOverlay })) : overlay.kind === "jobs" ? (_jsx(JobsPanel, { jobs: jobs, onClose: closeOverlay })) : overlay.kind === "picker" ? (_jsx(PickerPanel, { title: overlay.title, options: overlay.options, height: viewportH, onSelect: overlay.onSelect, onClose: closeOverlay })) : (_jsx(Box, { flexDirection: "column", height: viewportH, children: visible.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line === "" ? " " : line }, i))) })), slashMenuOpen && !modalActive
|
|
861
|
+
? suggestions.map((cmd, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: i === selected ? "magenta" : "cyan", children: [i === selected ? "❯ " : " ", cmd.command] }), cmd.usage ? _jsxs(Text, { dimColor: true, children: [" ", cmd.usage] }) : null, _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.command)))
|
|
862
|
+
: null, fileMenuOpen && !modalActive
|
|
863
|
+
? fileSuggestions.map((file, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: i === selected ? "magenta" : file.isDir ? "cyan" : "white", bold: i === selected, children: [i === selected ? "❯ " : " ", file.isDir ? "▸ " : "· ", file.value] }), _jsx(Text, { dimColor: true, children: file.isDir ? " directory" : " attach file" })] }, file.value)))
|
|
864
|
+
: null, secretRequest ? (_jsx(SecretInputPanel, { title: secretRequest.title, prompt: secretRequest.prompt, onSubmit: (value) => answerSecret(value), onCancel: () => answerSecret(undefined) })) : state.pendingConfirm ? (_jsx(ConfirmModal, { confirm: state.pendingConfirm, onAnswer: answerConfirm })) : (_jsx(Box, { children: compacting ? (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", children: [spinner, " "] }), _jsx(Text, { color: "yellow", children: "compacting conversation\u2026" })] })) : state.status.running ? (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", children: [spinner, " "] }), _jsx(Text, { color: "yellow", children: state.status.activity || "working" }), state.status.step > 0 ? _jsx(Text, { dimColor: true, children: ` · step ${state.status.step}` }) : null, _jsx(Text, { dimColor: true, children: ` · ${elapsed}s · esc to cancel` }), state.queued.length > 0 ? _jsx(Text, { dimColor: true, children: ` · ${state.queued.length} queued` }) : null] })) : (_jsxs(Text, { dimColor: true, children: ["ready", state.queued.length > 0 ? ` · ${state.queued.length} queued` : "", " · / commands · ctrl+t thinking · ctrl+o output"] })) })), _jsxs(Box, { borderStyle: "round", borderColor: state.pendingConfirm || secretRequest ? "yellow" : state.status.running ? "yellow" : "magenta", paddingX: 1, children: [_jsx(Text, { color: state.status.running ? "yellow" : "magenta", bold: true, children: state.pendingConfirm || secretRequest ? "! " : "❯ " }), secretRequest ? (_jsx(Text, { bold: true, children: "Input locked \u00B7 complete the secure input above" })) : state.pendingConfirm ? (_jsx(Text, { bold: true, children: "Input locked \u00B7 answer the confirmation above with Y or N" })) : input.length === 0 ? (_jsx(Text, { dimColor: true, children: state.status.running
|
|
865
|
+
? "type to queue a message…"
|
|
866
|
+
: "ask anything · / for commands · @file to attach · esc to cancel" })) : (_jsxs(Text, { wrap: "truncate-start", children: [before, _jsx(Text, { inverse: true, children: at }), after] }))] })] }));
|
|
272
867
|
}
|
|
273
868
|
//# sourceMappingURL=App.js.map
|