@este.systems/dsc 0.1.6 → 0.2.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 +111 -31
- package/bin/dsc.mjs +13 -3
- package/dist/agent.js +76 -14
- package/dist/agent.js.map +1 -1
- package/dist/editor.js +37 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.js +18 -59
- package/dist/index.js.map +1 -1
- package/dist/prompt.js +11 -5
- package/dist/prompt.js.map +1 -1
- package/dist/slash.js +59 -0
- package/dist/slash.js.map +1 -0
- package/dist/store.js +44 -0
- package/dist/store.js.map +1 -0
- package/dist/tools.js +145 -1
- package/dist/tools.js.map +1 -1
- package/dist/tui/AgentTaskList.js +31 -0
- package/dist/tui/AgentTaskList.js.map +1 -0
- package/dist/tui/App.js +53 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/ApprovalDialog.js +24 -0
- package/dist/tui/ApprovalDialog.js.map +1 -0
- package/dist/tui/CurrentTurn.js +13 -0
- package/dist/tui/CurrentTurn.js.map +1 -0
- package/dist/tui/History.js +43 -0
- package/dist/tui/History.js.map +1 -0
- package/dist/tui/Input.js +162 -0
- package/dist/tui/Input.js.map +1 -0
- package/dist/tui/Markdown.js +242 -0
- package/dist/tui/Markdown.js.map +1 -0
- package/dist/tui/PromptInput.js +55 -0
- package/dist/tui/PromptInput.js.map +1 -0
- package/dist/tui/QueuedPrompts.js +13 -0
- package/dist/tui/QueuedPrompts.js.map +1 -0
- package/dist/tui/StatusBar.js +50 -0
- package/dist/tui/StatusBar.js.map +1 -0
- package/dist/tui/TaskLine.js +12 -0
- package/dist/tui/TaskLine.js.map +1 -0
- package/dist/tui/useStore.js +17 -0
- package/dist/tui/useStore.js.map +1 -0
- package/dist/tui.js +786 -0
- package/dist/tui.js.map +1 -0
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -0
- package/package.json +7 -2
package/dist/tui.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { App } from "./tui/App.js";
|
|
4
|
+
import { getState, setState } from "./store.js";
|
|
5
|
+
import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, computeCostUsd, configPath, hasApiKey, recordUsage, } from "./api.js";
|
|
6
|
+
import { runAgent, formatCost, estimateContextTokens, } from "./agent.js";
|
|
7
|
+
import * as history from "./history.js";
|
|
8
|
+
import * as approval from "./approval.js";
|
|
9
|
+
import * as audit from "./audit.js";
|
|
10
|
+
import * as replHistory from "./repl_history.js";
|
|
11
|
+
import { promises as fsp } from "node:fs";
|
|
12
|
+
import { compactSession } from "./compact.js";
|
|
13
|
+
import { formatVersionInfo } from "./version.js";
|
|
14
|
+
import { openEditor } from "./editor.js";
|
|
15
|
+
const AUTO_COMPACT_AT_TOKENS = Number(process.env.DSC_AUTO_COMPACT ?? "0") || 0;
|
|
16
|
+
const AUTO_COMPACT_KEEP = Number(process.env.DSC_AUTO_COMPACT_KEEP ?? "4") || 4;
|
|
17
|
+
// Minimal arg parsing for the TUI entry. Mirrors the subset of flags the
|
|
18
|
+
// REPL accepts that meaningfully affect a one-shot turn. Anything fancier
|
|
19
|
+
// (e.g. --model, --resume <id>) still has to go through --repl for now.
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const out = {};
|
|
22
|
+
const positional = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
if (a === "--version" || a === "-v")
|
|
26
|
+
out.version = true;
|
|
27
|
+
else if (a === "--help" || a === "-h")
|
|
28
|
+
out.help = true;
|
|
29
|
+
else if (a === "--yolo" || a === "-y")
|
|
30
|
+
out.yolo = true;
|
|
31
|
+
else if (a === "--no-resume")
|
|
32
|
+
out.noResume = true;
|
|
33
|
+
else if (a.startsWith("-")) {
|
|
34
|
+
// Unknown flags fall through silently — bin routes --repl-only flags
|
|
35
|
+
// (--model, --resume <id>) to index.ts instead.
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
positional.push(a);
|
|
39
|
+
}
|
|
40
|
+
if (positional.length)
|
|
41
|
+
out.prompt = positional.join(" ");
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
async function main() {
|
|
45
|
+
const cli = parseArgs(process.argv.slice(2));
|
|
46
|
+
if (cli.version) {
|
|
47
|
+
process.stdout.write(formatVersionInfo() + "\n");
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
if (cli.help) {
|
|
51
|
+
process.stdout.write([
|
|
52
|
+
"dsc — CLI coding agent for DeepSeek (TUI)",
|
|
53
|
+
"",
|
|
54
|
+
"Usage:",
|
|
55
|
+
" dsc Start the TUI",
|
|
56
|
+
" dsc \"your prompt here\" One-shot: run and exit",
|
|
57
|
+
" dsc --repl Use the readline REPL instead",
|
|
58
|
+
"",
|
|
59
|
+
"Flags handled here:",
|
|
60
|
+
" -y, --yolo Skip approval prompts",
|
|
61
|
+
" --no-resume Don't auto-resume the latest session",
|
|
62
|
+
" -v, --version Print version and exit",
|
|
63
|
+
" -h, --help Show this help",
|
|
64
|
+
"",
|
|
65
|
+
"All other flags (--model, --resume <id>) go through --repl.",
|
|
66
|
+
].join("\n") + "\n");
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
if (!hasApiKey()) {
|
|
70
|
+
process.stderr.write(`No DeepSeek API key found.\n` +
|
|
71
|
+
`Either export DEEPSEEK_API_KEY, or create ${configPath()} containing:\n` +
|
|
72
|
+
` {"api_key": "sk-..."}\n`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
await history.migrateLegacyIfPresent(cwd, DEFAULT_MODEL);
|
|
77
|
+
// Auto-resume most recent for cwd unless --no-resume; otherwise fresh.
|
|
78
|
+
let session = history.newSession(cwd, DEFAULT_MODEL);
|
|
79
|
+
if (!cli.noResume) {
|
|
80
|
+
const target = await history.mostRecentForCwd(cwd);
|
|
81
|
+
if (target) {
|
|
82
|
+
const loaded = await history.loadSession(target.id);
|
|
83
|
+
if (loaded)
|
|
84
|
+
session = loaded;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Reassigned by /clear and /resume — keep `let` so handlers can swap them
|
|
88
|
+
// out for the new session's arrays without restarting the process.
|
|
89
|
+
let messages = session.messages;
|
|
90
|
+
let stats = session.stats;
|
|
91
|
+
let model = session.model;
|
|
92
|
+
const initialAutoContinue = Number(process.env.DSC_AUTO_CONTINUE ?? "0") || 0;
|
|
93
|
+
const toolCtx = {
|
|
94
|
+
cwd,
|
|
95
|
+
yolo: !!cli.yolo,
|
|
96
|
+
filesTouched: stats.files_touched,
|
|
97
|
+
sessionId: session.id,
|
|
98
|
+
};
|
|
99
|
+
// Coalescing save (same shape as REPL).
|
|
100
|
+
let savePromise = null;
|
|
101
|
+
let savePending = false;
|
|
102
|
+
const persist = () => {
|
|
103
|
+
savePending = true;
|
|
104
|
+
if (savePromise)
|
|
105
|
+
return savePromise;
|
|
106
|
+
savePromise = (async () => {
|
|
107
|
+
while (savePending) {
|
|
108
|
+
savePending = false;
|
|
109
|
+
try {
|
|
110
|
+
session.model = model;
|
|
111
|
+
session.messages = messages;
|
|
112
|
+
session.stats = stats;
|
|
113
|
+
await history.saveSession(session);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Swallow — the next turn will retry; no stderr in TUI mode.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
savePromise = null;
|
|
120
|
+
})();
|
|
121
|
+
return savePromise;
|
|
122
|
+
};
|
|
123
|
+
const sessionStart = Date.now();
|
|
124
|
+
const promptQueue = [];
|
|
125
|
+
// Push the freshly-computed numbers into the store; StatusBar reads from
|
|
126
|
+
// there. Called after every onTurn and once per second for the timer.
|
|
127
|
+
const syncStatus = () => {
|
|
128
|
+
setState({
|
|
129
|
+
model,
|
|
130
|
+
contextTokens: estimateContextTokens(messages),
|
|
131
|
+
sessionSeconds: Math.floor((Date.now() - sessionStart) / 1000),
|
|
132
|
+
inTokens: stats.prompt_tokens,
|
|
133
|
+
outTokens: stats.completion_tokens,
|
|
134
|
+
cacheHitTokens: stats.cache_hit_tokens,
|
|
135
|
+
cacheMissTokens: stats.cache_miss_tokens,
|
|
136
|
+
toolCalls: stats.tool_calls_total,
|
|
137
|
+
queue: promptQueue.slice(),
|
|
138
|
+
queueDepth: promptQueue.length,
|
|
139
|
+
compacted: !!session.compaction,
|
|
140
|
+
cost: computeCostUsd(stats, model),
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
setState({
|
|
144
|
+
model,
|
|
145
|
+
yolo: toolCtx.yolo,
|
|
146
|
+
assistantLabel: session.assistantLabel ?? "assistant:",
|
|
147
|
+
language: session.language,
|
|
148
|
+
autoContinue: initialAutoContinue,
|
|
149
|
+
});
|
|
150
|
+
// Seed history from the restored session so prior turns are visible.
|
|
151
|
+
if (messages.length) {
|
|
152
|
+
const restored = [];
|
|
153
|
+
for (const m of messages) {
|
|
154
|
+
if (m.role === "system")
|
|
155
|
+
continue;
|
|
156
|
+
restored.push({
|
|
157
|
+
id: `r-${restored.length}`,
|
|
158
|
+
role: m.role,
|
|
159
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
160
|
+
tool_call_id: m.tool_call_id,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
setState({ history: restored });
|
|
164
|
+
}
|
|
165
|
+
syncStatus();
|
|
166
|
+
// 1-second tick so the session timer in StatusBar advances even when idle.
|
|
167
|
+
const timerId = setInterval(syncStatus, 1000);
|
|
168
|
+
// Install the approval asker — routes confirm* calls through the
|
|
169
|
+
// ApprovalDialog component. The diff/preview body printed by confirm*
|
|
170
|
+
// still goes to stdout and will appear above ink's dynamic frame; that's
|
|
171
|
+
// acceptable for a first cut and gets cleaned up in a later commit.
|
|
172
|
+
approval.setAsker((q) => new Promise((resolve) => {
|
|
173
|
+
setState({
|
|
174
|
+
approval: {
|
|
175
|
+
title: "Confirm",
|
|
176
|
+
body: "",
|
|
177
|
+
question: q,
|
|
178
|
+
resolve,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}));
|
|
182
|
+
// Wire events: agent → store.
|
|
183
|
+
const events = {
|
|
184
|
+
onAssistantStart: (turnId) => {
|
|
185
|
+
setState({
|
|
186
|
+
current: { id: turnId, role: "assistant", content: "", reasoning: "" },
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
onAssistantContent: (turnId, chunk) => {
|
|
190
|
+
setState((s) => {
|
|
191
|
+
const cur = s.current && s.current.id === turnId
|
|
192
|
+
? s.current
|
|
193
|
+
: { id: turnId, role: "assistant", content: "", reasoning: "" };
|
|
194
|
+
return { current: { ...cur, content: (cur.content ?? "") + chunk } };
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
onAssistantReasoning: (turnId, chunk) => {
|
|
198
|
+
setState((s) => {
|
|
199
|
+
const cur = s.current && s.current.id === turnId
|
|
200
|
+
? s.current
|
|
201
|
+
: { id: turnId, role: "assistant", content: "", reasoning: "" };
|
|
202
|
+
return { current: { ...cur, reasoning: (cur.reasoning ?? "") + chunk } };
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
onAssistantFinal: (turnId, msg) => {
|
|
206
|
+
// Move from `current` into the static history (so it's selectable).
|
|
207
|
+
// Drop assistant turns that produced neither content nor reasoning
|
|
208
|
+
// (tool-only turns) — the tool messages themselves will appear.
|
|
209
|
+
const has = (msg.content && msg.content.length) || (msg.reasoning && msg.reasoning.length);
|
|
210
|
+
setState((s) => {
|
|
211
|
+
const next = { current: null };
|
|
212
|
+
if (has) {
|
|
213
|
+
const final = {
|
|
214
|
+
id: turnId,
|
|
215
|
+
role: "assistant",
|
|
216
|
+
content: msg.content,
|
|
217
|
+
reasoning: msg.reasoning,
|
|
218
|
+
tool_calls: msg.tool_calls,
|
|
219
|
+
};
|
|
220
|
+
next.history = [...s.history, final];
|
|
221
|
+
}
|
|
222
|
+
return next;
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
onToolStart: (_callId, name, args) => {
|
|
226
|
+
setState({ task: `${name}(${truncate(args, 80)})` });
|
|
227
|
+
},
|
|
228
|
+
onToolEnd: (callId, name, content, rejected) => {
|
|
229
|
+
setState((s) => ({
|
|
230
|
+
task: null,
|
|
231
|
+
history: [
|
|
232
|
+
...s.history,
|
|
233
|
+
{
|
|
234
|
+
id: callId,
|
|
235
|
+
role: "tool",
|
|
236
|
+
content,
|
|
237
|
+
tool_call_id: callId,
|
|
238
|
+
tool_name: rejected ? `${name} (rejected)` : name,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
}));
|
|
242
|
+
},
|
|
243
|
+
onNotice: (text) => {
|
|
244
|
+
setState((s) => ({
|
|
245
|
+
history: [
|
|
246
|
+
...s.history,
|
|
247
|
+
{ id: `n-${s.history.length}`, role: "system", content: text },
|
|
248
|
+
],
|
|
249
|
+
}));
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
let pendingAbort = null;
|
|
253
|
+
const runTurn = async (userText) => {
|
|
254
|
+
// Push the user message into history immediately so it's visible before
|
|
255
|
+
// the model responds.
|
|
256
|
+
setState((s) => ({
|
|
257
|
+
history: [
|
|
258
|
+
...s.history,
|
|
259
|
+
{ id: `u-${s.history.length}`, role: "user", content: userText },
|
|
260
|
+
],
|
|
261
|
+
busy: true,
|
|
262
|
+
}));
|
|
263
|
+
messages.push({ role: "user", content: userText });
|
|
264
|
+
pendingAbort = new AbortController();
|
|
265
|
+
try {
|
|
266
|
+
await runAgent({
|
|
267
|
+
model,
|
|
268
|
+
stats,
|
|
269
|
+
toolCtx,
|
|
270
|
+
messages,
|
|
271
|
+
signal: pendingAbort.signal,
|
|
272
|
+
onTurn: () => {
|
|
273
|
+
syncStatus();
|
|
274
|
+
void persist();
|
|
275
|
+
},
|
|
276
|
+
showReasoning: getState().reasoning,
|
|
277
|
+
getSummary: () => session.compaction?.summary,
|
|
278
|
+
assistantLabel: session.assistantLabel,
|
|
279
|
+
maxAutoContinue: getState().autoContinue,
|
|
280
|
+
language: session.language,
|
|
281
|
+
events,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
const text = e.name === "AbortError" || pendingAbort?.signal.aborted
|
|
286
|
+
? "(interrupted)"
|
|
287
|
+
: e instanceof DeepSeekError
|
|
288
|
+
? `API error ${e.status ?? ""}: ${e.message}`
|
|
289
|
+
: `error: ${e.message ?? e}`;
|
|
290
|
+
setState((s) => ({
|
|
291
|
+
history: [
|
|
292
|
+
...s.history,
|
|
293
|
+
{ id: `e-${s.history.length}`, role: "system", content: text },
|
|
294
|
+
],
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
pendingAbort = null;
|
|
299
|
+
setState({ busy: false, task: null });
|
|
300
|
+
}
|
|
301
|
+
await persist();
|
|
302
|
+
};
|
|
303
|
+
// Drain queued prompts after each turn so back-to-back submissions run in
|
|
304
|
+
// order without overlapping turns.
|
|
305
|
+
let draining = false;
|
|
306
|
+
const drain = async () => {
|
|
307
|
+
if (draining)
|
|
308
|
+
return;
|
|
309
|
+
draining = true;
|
|
310
|
+
try {
|
|
311
|
+
while (promptQueue.length) {
|
|
312
|
+
const next = promptQueue.shift();
|
|
313
|
+
syncStatus();
|
|
314
|
+
await runTurn(next);
|
|
315
|
+
if (AUTO_COMPACT_AT_TOKENS > 0) {
|
|
316
|
+
const ctx = estimateContextTokens(messages);
|
|
317
|
+
if (ctx > AUTO_COMPACT_AT_TOKENS) {
|
|
318
|
+
await runCompaction(AUTO_COMPACT_KEEP, true);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
draining = false;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
// Persisted REPL history — both REPL and TUI share this on-disk file so
|
|
328
|
+
// arrow-up recall works across sessions and across the two front-ends.
|
|
329
|
+
const promptHistory = await replHistory.load();
|
|
330
|
+
// Forward declarations so handleSlash can reference the ink instance and
|
|
331
|
+
// remount helper for /edit (which unmounts ink, runs $EDITOR, remounts).
|
|
332
|
+
// These get assigned just before the first render at the bottom of main().
|
|
333
|
+
let inkInstance = null;
|
|
334
|
+
const mountApp = () => {
|
|
335
|
+
inkInstance = render(_jsx(App, { onSubmit: handleSubmit, onAbort: handleAbort, history: promptHistory }));
|
|
336
|
+
};
|
|
337
|
+
// Push a one-line dim system notice into history (used for slash-command
|
|
338
|
+
// output — the dim styling comes from History's role==="system" treatment).
|
|
339
|
+
const info = (text) => {
|
|
340
|
+
setState((s) => ({
|
|
341
|
+
history: [
|
|
342
|
+
...s.history,
|
|
343
|
+
{ id: `i-${s.history.length}`, role: "system", content: text },
|
|
344
|
+
],
|
|
345
|
+
}));
|
|
346
|
+
};
|
|
347
|
+
const runCompaction = async (keep, auto) => {
|
|
348
|
+
const beforeMessages = messages.length;
|
|
349
|
+
const beforeChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
|
|
350
|
+
if (auto)
|
|
351
|
+
info(`── auto-compact (ctx > ${AUTO_COMPACT_AT_TOKENS} tokens)`);
|
|
352
|
+
try {
|
|
353
|
+
const result = await compactSession(session, keep, model);
|
|
354
|
+
if (!result) {
|
|
355
|
+
if (!auto)
|
|
356
|
+
info(`nothing to compact (need more than ${keep} user turns)`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
session.archivedMessages = [
|
|
360
|
+
...(session.archivedMessages ?? []),
|
|
361
|
+
...result.droppedMessages,
|
|
362
|
+
];
|
|
363
|
+
session.messages = result.remainingMessages;
|
|
364
|
+
messages = session.messages;
|
|
365
|
+
session.compaction = {
|
|
366
|
+
summary: result.summary,
|
|
367
|
+
compacted_at: Date.now(),
|
|
368
|
+
turns_removed: (session.compaction?.turns_removed ?? 0) + result.turnsRemoved,
|
|
369
|
+
};
|
|
370
|
+
stats.prompts += 1;
|
|
371
|
+
recordUsage(stats, result.usage);
|
|
372
|
+
const afterChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
|
|
373
|
+
info(`compacted ${result.turnsRemoved} user turn(s); messages ${beforeMessages} → ${messages.length}; chars ${beforeChars} → ${afterChars} + ${result.summary.length} summary`);
|
|
374
|
+
syncStatus();
|
|
375
|
+
await persist();
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
info(e instanceof DeepSeekError
|
|
379
|
+
? `error: compaction failed: ${e.message}`
|
|
380
|
+
: `error: compaction failed: ${e.message}`);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
// Returns true if the input was a recognized slash command and got handled
|
|
384
|
+
// (or rejected) — caller should not forward it to the agent. Returns false
|
|
385
|
+
// for non-slash input and unknown commands (caller decides what to do).
|
|
386
|
+
const handleSlash = async (line) => {
|
|
387
|
+
if (!line.startsWith("/"))
|
|
388
|
+
return false;
|
|
389
|
+
const [cmd, ...rest] = line.slice(1).split(/\s+/);
|
|
390
|
+
const arg = rest.join(" ");
|
|
391
|
+
switch (cmd) {
|
|
392
|
+
case "exit":
|
|
393
|
+
case "quit":
|
|
394
|
+
clearInterval(timerId);
|
|
395
|
+
process.exit(0);
|
|
396
|
+
return true;
|
|
397
|
+
case "help":
|
|
398
|
+
info([
|
|
399
|
+
"/help show this help",
|
|
400
|
+
"/clear start a new session",
|
|
401
|
+
"/list list sessions for this cwd",
|
|
402
|
+
"/resume [n|name|id] resume a session",
|
|
403
|
+
"/save <name> name the current session",
|
|
404
|
+
"/rename <text> set assistant label for this session",
|
|
405
|
+
"/model [name] show or switch model",
|
|
406
|
+
"/yolo toggle approval mode",
|
|
407
|
+
"/reasoning [on|off] toggle reasoning display",
|
|
408
|
+
"/lang [name|off] force the model to reply in a language",
|
|
409
|
+
"/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
|
|
410
|
+
"/cost show token usage and cost",
|
|
411
|
+
"/version show version info",
|
|
412
|
+
"/compact [keep] summarize old turns (default keep=4)",
|
|
413
|
+
"/transcript dump full message log",
|
|
414
|
+
"/audit [path|show N] audit log info",
|
|
415
|
+
"/queue [clear] list or clear queued prompts",
|
|
416
|
+
"/exit exit",
|
|
417
|
+
].join("\n"));
|
|
418
|
+
return true;
|
|
419
|
+
case "clear":
|
|
420
|
+
session = history.newSession(cwd, model);
|
|
421
|
+
messages = session.messages;
|
|
422
|
+
stats = session.stats;
|
|
423
|
+
toolCtx.filesTouched = stats.files_touched;
|
|
424
|
+
toolCtx.sessionId = session.id;
|
|
425
|
+
setState({ history: [], current: null });
|
|
426
|
+
info(`new session started (${session.id})`);
|
|
427
|
+
syncStatus();
|
|
428
|
+
return true;
|
|
429
|
+
case "list": {
|
|
430
|
+
const all = await history.listSessions(cwd);
|
|
431
|
+
if (!all.length) {
|
|
432
|
+
info(`no sessions for ${cwd}`);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
info(all
|
|
436
|
+
.map((s, i) => {
|
|
437
|
+
const here = s.id === session.id ? "* " : " ";
|
|
438
|
+
const label = s.name ? `${s.name} (${s.model})` : s.model;
|
|
439
|
+
return `${here}${String(i + 1).padStart(2, " ")}. ${label} ${formatRelative(s.updated_at)} (${s.message_count} msgs) ${s.first_user_message || "—"}`;
|
|
440
|
+
})
|
|
441
|
+
.join("\n"));
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
case "save":
|
|
446
|
+
if (!arg.trim()) {
|
|
447
|
+
info("error: usage: /save <name>");
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
session.name = arg.trim();
|
|
451
|
+
await persist();
|
|
452
|
+
info(`session saved as "${session.name}" (id ${session.id})`);
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
case "rename": {
|
|
456
|
+
const text = arg.trim();
|
|
457
|
+
if (!text) {
|
|
458
|
+
info(`assistant label: "${session.assistantLabel ?? "assistant:"}"`);
|
|
459
|
+
}
|
|
460
|
+
else if (text === "--reset" || text === "default") {
|
|
461
|
+
delete session.assistantLabel;
|
|
462
|
+
setState({ assistantLabel: "assistant:" });
|
|
463
|
+
await persist();
|
|
464
|
+
info("assistant label reset to default");
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
session.assistantLabel = text;
|
|
468
|
+
setState({ assistantLabel: text });
|
|
469
|
+
await persist();
|
|
470
|
+
info(`assistant label → "${text}"`);
|
|
471
|
+
}
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
case "resume": {
|
|
475
|
+
const all = await history.listSessions(cwd);
|
|
476
|
+
if (!all.length) {
|
|
477
|
+
info("no sessions to resume");
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
let target = null;
|
|
481
|
+
if (!arg || arg === "last") {
|
|
482
|
+
target = all[0];
|
|
483
|
+
}
|
|
484
|
+
else if (/^\d+$/.test(arg)) {
|
|
485
|
+
target = all[parseInt(arg, 10) - 1] ?? null;
|
|
486
|
+
if (!target)
|
|
487
|
+
info(`error: no session at index ${arg} (have ${all.length})`);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
target =
|
|
491
|
+
all.find((s) => s.name === arg) ??
|
|
492
|
+
all.find((s) => s.id === arg) ??
|
|
493
|
+
null;
|
|
494
|
+
if (!target)
|
|
495
|
+
info(`error: no session with name or id ${arg}`);
|
|
496
|
+
}
|
|
497
|
+
if (target) {
|
|
498
|
+
const loaded = await history.loadSession(target.id);
|
|
499
|
+
if (!loaded) {
|
|
500
|
+
info(`error: failed to load session ${target.id}`);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
session = loaded;
|
|
504
|
+
messages = session.messages;
|
|
505
|
+
stats = session.stats;
|
|
506
|
+
model = session.model;
|
|
507
|
+
toolCtx.filesTouched = stats.files_touched;
|
|
508
|
+
toolCtx.sessionId = session.id;
|
|
509
|
+
const userTurns = messages.filter((m) => m.role === "user").length;
|
|
510
|
+
// Rebuild history view from the resumed session.
|
|
511
|
+
const restored = [];
|
|
512
|
+
for (const m of messages) {
|
|
513
|
+
if (m.role === "system")
|
|
514
|
+
continue;
|
|
515
|
+
restored.push({
|
|
516
|
+
id: `r-${restored.length}`,
|
|
517
|
+
role: m.role,
|
|
518
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
519
|
+
tool_call_id: m.tool_call_id,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
setState({ history: restored, current: null, model });
|
|
523
|
+
syncStatus();
|
|
524
|
+
info(`resumed ${session.id} (${userTurns} turns, model ${model})`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
case "cost":
|
|
530
|
+
info(formatCost(stats, model));
|
|
531
|
+
return true;
|
|
532
|
+
case "model":
|
|
533
|
+
if (!arg) {
|
|
534
|
+
info(`current model: ${model}`);
|
|
535
|
+
}
|
|
536
|
+
else if (!AVAILABLE_MODELS.includes(arg)) {
|
|
537
|
+
info(`error: unknown model: ${arg} (available: ${AVAILABLE_MODELS.join(", ")})`);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
model = arg;
|
|
541
|
+
syncStatus();
|
|
542
|
+
info(`model → ${model}`);
|
|
543
|
+
await persist();
|
|
544
|
+
}
|
|
545
|
+
return true;
|
|
546
|
+
case "yolo":
|
|
547
|
+
toolCtx.yolo = !toolCtx.yolo;
|
|
548
|
+
setState({ yolo: toolCtx.yolo });
|
|
549
|
+
info(`yolo: ${toolCtx.yolo}`);
|
|
550
|
+
return true;
|
|
551
|
+
case "reasoning": {
|
|
552
|
+
const cur = getState().reasoning;
|
|
553
|
+
const next = arg === "on" ? true : arg === "off" ? false : !cur;
|
|
554
|
+
setState({ reasoning: next });
|
|
555
|
+
info(`reasoning: ${next ? "on" : "off"}`);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
case "queue": {
|
|
559
|
+
const sub = arg.trim().toLowerCase();
|
|
560
|
+
if (sub === "clear" || sub === "drop") {
|
|
561
|
+
const n = promptQueue.length;
|
|
562
|
+
promptQueue.length = 0;
|
|
563
|
+
syncStatus();
|
|
564
|
+
info(`cleared ${n} queued prompt(s)`);
|
|
565
|
+
}
|
|
566
|
+
else if (promptQueue.length === 0) {
|
|
567
|
+
info("queue is empty");
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
info(promptQueue
|
|
571
|
+
.map((p, i) => `${String(i + 1).padStart(2, " ")}. ${p}`)
|
|
572
|
+
.join("\n"));
|
|
573
|
+
}
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
case "lang": {
|
|
577
|
+
const text = arg.trim();
|
|
578
|
+
if (!text) {
|
|
579
|
+
info(`language: ${session.language ? `"${session.language}"` : "off (any language)"}`);
|
|
580
|
+
}
|
|
581
|
+
else if (text === "off" || text === "default" || text === "any") {
|
|
582
|
+
delete session.language;
|
|
583
|
+
setState({ language: undefined });
|
|
584
|
+
await persist();
|
|
585
|
+
info("language directive cleared");
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
session.language = text;
|
|
589
|
+
setState({ language: text });
|
|
590
|
+
await persist();
|
|
591
|
+
info(`language → "${text}" (replies will be exclusively in this language)`);
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
case "auto-continue": {
|
|
596
|
+
const t = arg.trim();
|
|
597
|
+
if (!t) {
|
|
598
|
+
const n = getState().autoContinue;
|
|
599
|
+
info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
|
|
600
|
+
}
|
|
601
|
+
else if (t === "off" || t === "0" || t === "false") {
|
|
602
|
+
setState({ autoContinue: 0 });
|
|
603
|
+
info("auto-continue: off");
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
const n = parseInt(t, 10);
|
|
607
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
608
|
+
info("error: usage: /auto-continue [N|off]");
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
setState({ autoContinue: n });
|
|
612
|
+
info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
case "version":
|
|
618
|
+
info(formatVersionInfo());
|
|
619
|
+
return true;
|
|
620
|
+
case "audit": {
|
|
621
|
+
const sub = arg.trim();
|
|
622
|
+
if (!sub || sub === "path") {
|
|
623
|
+
info(audit.auditLogPath());
|
|
624
|
+
}
|
|
625
|
+
else if (sub.startsWith("show")) {
|
|
626
|
+
const nRaw = sub.replace(/^show\s*/, "").trim();
|
|
627
|
+
const limit = (() => {
|
|
628
|
+
const n = nRaw ? parseInt(nRaw, 10) : NaN;
|
|
629
|
+
return Number.isFinite(n) && n > 0 ? n : 10;
|
|
630
|
+
})();
|
|
631
|
+
try {
|
|
632
|
+
const text = await fsp.readFile(audit.auditLogPath(), "utf8");
|
|
633
|
+
const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
|
|
634
|
+
info(lines.length ? lines.join("\n") : "(empty)");
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
info("(no audit log yet)");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
info("error: usage: /audit | /audit path | /audit show [N]");
|
|
642
|
+
}
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
case "transcript": {
|
|
646
|
+
const archived = session.archivedMessages ?? [];
|
|
647
|
+
if (archived.length === 0 && messages.length === 0) {
|
|
648
|
+
info("(no messages)");
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
const lines = [];
|
|
652
|
+
const renderMsg = (m, isArchived) => {
|
|
653
|
+
const tag = isArchived ? "archived " : "";
|
|
654
|
+
lines.push(`\n${tag}${m.role}`);
|
|
655
|
+
if (m.tool_calls && m.tool_calls.length) {
|
|
656
|
+
for (const tc of m.tool_calls) {
|
|
657
|
+
lines.push(` → ${tc.function.name}(${truncate(tc.function.arguments, 200)})`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (m.tool_call_id)
|
|
661
|
+
lines.push(` ← tool_call_id: ${m.tool_call_id}`);
|
|
662
|
+
if (typeof m.content === "string" && m.content)
|
|
663
|
+
lines.push(m.content);
|
|
664
|
+
};
|
|
665
|
+
if (archived.length) {
|
|
666
|
+
lines.push(`── archived (${archived.length} messages)`);
|
|
667
|
+
for (const m of archived)
|
|
668
|
+
renderMsg(m, true);
|
|
669
|
+
}
|
|
670
|
+
if (messages.length) {
|
|
671
|
+
lines.push(`── active (${messages.length} messages)`);
|
|
672
|
+
for (const m of messages)
|
|
673
|
+
renderMsg(m, false);
|
|
674
|
+
}
|
|
675
|
+
info(lines.join("\n"));
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
case "compact": {
|
|
679
|
+
const keepRaw = arg ? parseInt(arg, 10) : NaN;
|
|
680
|
+
const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
|
|
681
|
+
await runCompaction(keep, false);
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
case "edit": {
|
|
685
|
+
// ink owns the terminal — unmount before spawning $EDITOR so the
|
|
686
|
+
// editor gets a clean screen. We also clear `history` before the
|
|
687
|
+
// unmount, otherwise the freshly-rendered <Static> after remount
|
|
688
|
+
// would emit all prior turns to scrollback a second time. The
|
|
689
|
+
// already-printed scrollback above stays untouched.
|
|
690
|
+
const initial = arg ? arg + "\n" : "";
|
|
691
|
+
setState({ history: [] });
|
|
692
|
+
inkInstance?.unmount();
|
|
693
|
+
const draft = openEditor(initial);
|
|
694
|
+
mountApp();
|
|
695
|
+
if (draft === null) {
|
|
696
|
+
info("error: editor failed");
|
|
697
|
+
}
|
|
698
|
+
else if (!draft.trim()) {
|
|
699
|
+
info("(empty draft, not sent)");
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
handleSubmit(draft);
|
|
703
|
+
}
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
default:
|
|
707
|
+
info(`error: unknown command: /${cmd}`);
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
const handleSubmit = (text) => {
|
|
712
|
+
// Persist every submitted line — slash commands included, since
|
|
713
|
+
// recalling "/resume 3" via arrow-up is useful.
|
|
714
|
+
if (text.trim()) {
|
|
715
|
+
promptHistory.push(text);
|
|
716
|
+
void replHistory.append(text);
|
|
717
|
+
}
|
|
718
|
+
if (text.startsWith("/")) {
|
|
719
|
+
void handleSlash(text);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
promptQueue.push(text);
|
|
723
|
+
syncStatus();
|
|
724
|
+
void drain();
|
|
725
|
+
};
|
|
726
|
+
const handleAbort = () => {
|
|
727
|
+
if (pendingAbort && !pendingAbort.signal.aborted) {
|
|
728
|
+
pendingAbort.abort();
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
if (cli.prompt) {
|
|
732
|
+
// One-shot: run the agent against the prompt with stdout-mode output
|
|
733
|
+
// (no events, no ink) and exit. Session state still loads + persists so
|
|
734
|
+
// subsequent interactive runs see the new turn.
|
|
735
|
+
clearInterval(timerId);
|
|
736
|
+
messages.push({ role: "user", content: cli.prompt });
|
|
737
|
+
pendingAbort = new AbortController();
|
|
738
|
+
try {
|
|
739
|
+
await runAgent({
|
|
740
|
+
model,
|
|
741
|
+
stats,
|
|
742
|
+
toolCtx,
|
|
743
|
+
messages,
|
|
744
|
+
signal: pendingAbort.signal,
|
|
745
|
+
onTurn: () => void persist(),
|
|
746
|
+
showReasoning: getState().reasoning,
|
|
747
|
+
getSummary: () => session.compaction?.summary,
|
|
748
|
+
assistantLabel: session.assistantLabel,
|
|
749
|
+
maxAutoContinue: getState().autoContinue,
|
|
750
|
+
language: session.language,
|
|
751
|
+
// No events: agent writes its assistant header, content, tool
|
|
752
|
+
// arrows, and notices directly to stdout — same as REPL one-shot.
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
catch (e) {
|
|
756
|
+
if (e.name !== "AbortError" && !pendingAbort?.signal.aborted) {
|
|
757
|
+
process.stderr.write(`\n${e.message ?? "error"}\n`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
await persist();
|
|
761
|
+
process.stdout.write(`\n${formatCost(stats, model)}\n`);
|
|
762
|
+
process.exit(0);
|
|
763
|
+
}
|
|
764
|
+
mountApp();
|
|
765
|
+
}
|
|
766
|
+
function truncate(s, n) {
|
|
767
|
+
return s.length <= n ? s : s.slice(0, n) + "…";
|
|
768
|
+
}
|
|
769
|
+
function formatRelative(ts) {
|
|
770
|
+
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
771
|
+
if (s < 60)
|
|
772
|
+
return `${s}s ago`;
|
|
773
|
+
const m = Math.floor(s / 60);
|
|
774
|
+
if (m < 60)
|
|
775
|
+
return `${m}m ago`;
|
|
776
|
+
const h = Math.floor(m / 60);
|
|
777
|
+
if (h < 24)
|
|
778
|
+
return `${h}h ago`;
|
|
779
|
+
const d = Math.floor(h / 24);
|
|
780
|
+
return `${d}d ago`;
|
|
781
|
+
}
|
|
782
|
+
main().catch((e) => {
|
|
783
|
+
process.stderr.write(`fatal: ${e.message ?? e}\n`);
|
|
784
|
+
process.exit(1);
|
|
785
|
+
});
|
|
786
|
+
//# sourceMappingURL=tui.js.map
|