@falkenslab/agent-kit 0.10.2 → 0.12.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 +91 -97
- package/dist/core/agentSpec.d.ts +31 -1
- package/dist/core/hooks/stepGate.js +3 -2
- package/dist/core/language.d.ts +39 -0
- package/dist/core/language.js +59 -0
- package/dist/core/messages/de.d.ts +2 -0
- package/dist/core/messages/de.js +103 -0
- package/dist/core/messages/en.d.ts +90 -0
- package/dist/core/messages/en.js +102 -0
- package/dist/core/messages/es.d.ts +2 -0
- package/dist/core/messages/es.js +103 -0
- package/dist/core/messages/fr.d.ts +2 -0
- package/dist/core/messages/fr.js +103 -0
- package/dist/core/messages/index.d.ts +23 -0
- package/dist/core/messages/index.js +67 -0
- package/dist/core/runs.d.ts +51 -0
- package/dist/core/runs.js +194 -0
- package/dist/core/session.d.ts +8 -0
- package/dist/core/session.js +42 -7
- package/dist/core/toolLabels.d.ts +2 -1
- package/dist/core/toolLabels.js +31 -51
- package/dist/core/tools/humanApproval.js +3 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/tui/chatTui.d.ts +23 -2
- package/dist/tui/chatTui.js +71 -7
- package/dist/tui/claudeAuth.d.ts +3 -1
- package/dist/tui/claudeAuth.js +12 -17
- package/dist/tui/consoleRenderer.js +2 -1
- package/dist/tui/ink/PromptInput.d.ts +0 -2
- package/dist/tui/ink/PromptInput.js +5 -15
- package/dist/tui/ink/SessionView.js +16 -15
- package/dist/tui/ink/inkInteraction.js +4 -7
- package/dist/tui/ink/progressView.d.ts +6 -0
- package/dist/tui/ink/progressView.js +2 -0
- package/dist/tui/ink/promptEditing.d.ts +2 -5
- package/dist/tui/ink/promptEditing.js +11 -3
- package/dist/tui/ink/runChatInk.d.ts +25 -2
- package/dist/tui/ink/runChatInk.js +156 -48
- package/dist/tui/ink/sessionModel.d.ts +10 -0
- package/dist/tui/ink/sessionModel.js +27 -2
- package/dist/tui/ink/toolGroup.d.ts +2 -1
- package/dist/tui/ink/toolGroup.js +5 -18
- package/dist/tui/ink/wizard.d.ts +6 -0
- package/dist/tui/ink/wizard.js +4 -1
- package/dist/tui/language.d.ts +5 -0
- package/dist/tui/language.js +11 -0
- package/dist/tui/runs.d.ts +27 -0
- package/dist/tui/runs.js +30 -0
- package/dist/tui/terminalInteraction.js +2 -2
- package/package.json +1 -1
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createWriteStream } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { stdin, stdout } from "node:process";
|
|
4
5
|
import { useSyncExternalStore } from "react";
|
|
5
6
|
import { Box, render, Text, useInput } from "ink";
|
|
7
|
+
import { Select } from "@inkjs/ui";
|
|
6
8
|
import { getInteractionPort, setInteractionPort } from "../../core/interaction.js";
|
|
7
9
|
import { createInputQueue } from "../../core/session.js";
|
|
8
10
|
import { runQuery } from "../../core/runner.js";
|
|
11
|
+
import { listRuns, readConversation } from "../../core/runs.js";
|
|
9
12
|
import { capHistory, loadHistory, runChatTui, saveHistory, slashCommandToken } from "../chatTui.js";
|
|
10
13
|
import * as ui from "../ui.js";
|
|
14
|
+
import { t } from "../../core/messages/index.js";
|
|
15
|
+
import { applyLanguage } from "../language.js";
|
|
16
|
+
import { firstRun, openSession, runDate, runFolderOf, runLabel } from "../runs.js";
|
|
11
17
|
import { createInkInteraction } from "./inkInteraction.js";
|
|
12
18
|
import { PromptInput } from "./PromptInput.js";
|
|
13
19
|
import { createSessionModel, liveWidth } from "./sessionModel.js";
|
|
14
20
|
import { SessionView } from "./SessionView.js";
|
|
15
|
-
import { stripAnsi } from "./lineBuffer.js";
|
|
21
|
+
import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
16
22
|
import { clipboardSequence, enterFullscreen } from "./fullscreen.js";
|
|
17
23
|
import { createCursorController, CursorContext } from "./terminalCursor.js";
|
|
18
24
|
import { createTerminalStatus, focusFromReport } from "./terminalStatus.js";
|
|
@@ -30,7 +36,7 @@ const PROMPT_FRAME_COLUMNS = 4;
|
|
|
30
36
|
*/
|
|
31
37
|
export function createChatInput() {
|
|
32
38
|
const listeners = new Set();
|
|
33
|
-
let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, closed: false };
|
|
39
|
+
let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, picker: null, closed: false };
|
|
34
40
|
let pending = null;
|
|
35
41
|
let exited = false;
|
|
36
42
|
function update(changes) {
|
|
@@ -74,6 +80,21 @@ export function createChatInput() {
|
|
|
74
80
|
setSuggestion: (suggestion) => update({ suggestion }),
|
|
75
81
|
setFiles: (files) => update({ files }),
|
|
76
82
|
setMode: (mode) => update({ mode }),
|
|
83
|
+
/** Shows a choice in place of the prompt; resolves with the value chosen, or null if cancelled. */
|
|
84
|
+
pick(title, options) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
update({
|
|
87
|
+
picker: {
|
|
88
|
+
title,
|
|
89
|
+
options,
|
|
90
|
+
resolve(value) {
|
|
91
|
+
update({ picker: null });
|
|
92
|
+
resolve(value);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
},
|
|
77
98
|
/** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
|
|
78
99
|
unqueueLast() {
|
|
79
100
|
const last = snapshot.queued.at(-1);
|
|
@@ -85,10 +106,21 @@ export function createChatInput() {
|
|
|
85
106
|
close: () => update({ prompting: false, closed: true }),
|
|
86
107
|
};
|
|
87
108
|
}
|
|
88
|
-
|
|
109
|
+
/** The /resume list, in place of the prompt: ↑/↓ and Enter choose, Esc cancels. */
|
|
110
|
+
function PickerPanel({ picker }) {
|
|
111
|
+
const { columns } = stdout;
|
|
112
|
+
useInput((_text, key) => {
|
|
113
|
+
if (key.escape)
|
|
114
|
+
picker.resolve(null);
|
|
115
|
+
});
|
|
116
|
+
const width = liveWidth(columns) - PROMPT_FRAME_COLUMNS;
|
|
117
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: picker.title }), _jsx(Select, { options: picker.options.map((option) => ({ ...option, label: fitWidth(option.label, Math.max(10, width - 2)) })), visibleOptionCount: Math.min(8, picker.options.length), onChange: (value) => picker.resolve(value) }), _jsx(Text, { dimColor: true, children: t().resumeHint })] }));
|
|
118
|
+
}
|
|
119
|
+
function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl: currentModeControl, onInterrupt }) {
|
|
89
120
|
const chat = useSyncExternalStore(input.subscribe, input.getSnapshot);
|
|
90
121
|
const session = useSyncExternalStore(model.subscribe, model.getSnapshot);
|
|
91
122
|
const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
|
123
|
+
const modeControl = currentModeControl();
|
|
92
124
|
// Ctrl+C stops what is running (a checkpoint, then the turn) and leaves only when idle;
|
|
93
125
|
// Esc interrupts the turn but never answers a checkpoint or leaves.
|
|
94
126
|
useInput((text, key) => {
|
|
@@ -104,7 +136,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
104
136
|
if (key.shift && key.tab) {
|
|
105
137
|
// Shift+Tab: the next mode this session allows.
|
|
106
138
|
if (!modeControl || modeControl.switchable.length < 2) {
|
|
107
|
-
model.note(ui.dim(
|
|
139
|
+
model.note(ui.dim(t().modeLocked(modeControl?.mode ?? mode)), "notice");
|
|
108
140
|
return;
|
|
109
141
|
}
|
|
110
142
|
const next = modeControl.switchable[(modeControl.switchable.indexOf(modeControl.mode) + 1) % modeControl.switchable.length];
|
|
@@ -112,6 +144,10 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
112
144
|
input.setMode(next);
|
|
113
145
|
return;
|
|
114
146
|
}
|
|
147
|
+
if (key.ctrl && text === "c" && chat.picker) {
|
|
148
|
+
chat.picker.resolve(null);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
115
151
|
if (key.ctrl && text === "c") {
|
|
116
152
|
if (checkpoint)
|
|
117
153
|
checkpoint.answer(checkpoint.kind === "decision" ? "q" : "");
|
|
@@ -124,7 +160,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
124
160
|
onInterrupt();
|
|
125
161
|
}
|
|
126
162
|
});
|
|
127
|
-
return (_jsx(SessionView, { model: model, interaction: interaction, renderApproval: renderApproval, mode: chat.mode ?? mode, modeSwitchable: (modeControl?.switchable.length ?? 0) > 1, closed: chat.closed, fullscreen: fullscreen, header: header, onCopy: fullscreen ? (text) => void stdout.write(clipboardSequence(text)) : undefined, children: _jsxs(Box, { flexDirection: "column", children: [chat.queued.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: ui.dim(`
|
|
163
|
+
return (_jsx(SessionView, { model: model, interaction: interaction, renderApproval: renderApproval, mode: chat.mode ?? mode, modeSwitchable: (modeControl?.switchable.length ?? 0) > 1, closed: chat.closed, fullscreen: fullscreen, header: header, onCopy: fullscreen ? (text) => void stdout.write(clipboardSequence(text)) : undefined, children: _jsxs(Box, { flexDirection: "column", children: [chat.queued.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: ui.dim(` ${t().queued(line)}`) }, index))), chat.picker ? (_jsx(PickerPanel, { picker: chat.picker })) : (_jsx(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(PromptInput, { label: promptLabel.replace(/^\n+/, ""), history: chat.history, commands: chat.commands, inset: PROMPT_FRAME_COLUMNS, suggestion: chat.suggestion, onSubmit: (line) => input.submit(line), onRecallQueued: () => input.unqueueLast(), files: chat.files, onExit: () => input.submit(null) }) }))] }) }));
|
|
128
164
|
}
|
|
129
165
|
/**
|
|
130
166
|
* The Ink counterpart of `runChatTui()`, with the same options, session log and history
|
|
@@ -135,19 +171,38 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
135
171
|
*
|
|
136
172
|
* With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
|
|
137
173
|
*
|
|
174
|
+
* Given a session opener instead of options, and `runsDir`, each run keeps its conversation
|
|
175
|
+
* in its own folder and can be resumed: `--continue` on the command line starts with the
|
|
176
|
+
* latest, and `/resume` picks one (see `ChatTuiOptions.runsDir`).
|
|
177
|
+
*
|
|
138
178
|
* Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
|
|
139
179
|
*/
|
|
140
180
|
export async function runChatInk(options, tuiOptions = {}) {
|
|
141
181
|
if (tuiOptions.plain || !stdin.isTTY || !stdout.isTTY)
|
|
142
182
|
return await runChatTui(options, tuiOptions);
|
|
183
|
+
// Warnings about an unsupported language code go into the chat, not behind the full screen.
|
|
184
|
+
const languageWarnings = [];
|
|
185
|
+
applyLanguage(tuiOptions.language, (line) => languageWarnings.push(line));
|
|
186
|
+
const opener = typeof options === "function" ? options : null;
|
|
187
|
+
const runsDir = opener ? tuiOptions.runsDir : undefined;
|
|
188
|
+
if (opener && !runsDir)
|
|
189
|
+
throw new Error("runChatInk(): a session opener needs `runsDir`.");
|
|
143
190
|
const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
|
|
144
191
|
const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
|
|
192
|
+
const userLine = (text) => `${promptLabel.replace(/^\n+/, "")}${text}`;
|
|
145
193
|
const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
146
194
|
let historyEntries = tuiOptions.historyPath ? capHistory(await loadHistory(tuiOptions.historyPath), historyLimit) : [];
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
195
|
+
// The session in use: with runs, the run's folder, rebuilt on /resume.
|
|
196
|
+
let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
|
|
197
|
+
const modeControl = () => current.modeControl ?? tuiOptions.modeControl;
|
|
198
|
+
// With runs, the session log is the run's own; it moves with /resume.
|
|
199
|
+
let sessionLog = null;
|
|
200
|
+
function openLog() {
|
|
201
|
+
sessionLog?.end();
|
|
202
|
+
const file = current.run ? path.join(current.run.dir, "session.log") : tuiOptions.sessionLogPath;
|
|
203
|
+
sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
|
|
204
|
+
}
|
|
205
|
+
openLog();
|
|
151
206
|
const mirror = (text) => void sessionLog?.write(stripAnsi(text));
|
|
152
207
|
const model = createSessionModel({
|
|
153
208
|
formatAction: tuiOptions.formatAction,
|
|
@@ -160,29 +215,19 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
160
215
|
const input = createChatInput();
|
|
161
216
|
if (tuiOptions.firstPromptSuggestion)
|
|
162
217
|
input.setSuggestion(tuiOptions.firstPromptSuggestion);
|
|
163
|
-
|
|
164
|
-
input.setMode(tuiOptions.modeControl.mode);
|
|
165
|
-
void listProjectFiles(options.cwd ?? process.cwd()).then((files) => input.setFiles(files));
|
|
166
|
-
// Every registered command name and alias, built-ins included, for completion and for
|
|
167
|
-
// catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
|
|
168
|
-
const knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
|
|
169
|
-
void knownCommands.then((names) => {
|
|
170
|
-
const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
|
|
171
|
-
const local = tuiOptions.terminalIntegration === false ? [] : ["copy"];
|
|
172
|
-
input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
|
|
173
|
-
});
|
|
218
|
+
void listProjectFiles(current.options.cwd ?? process.cwd()).then((files) => input.setFiles(files));
|
|
174
219
|
let turnInterrupted = false;
|
|
175
220
|
function interruptTurn() {
|
|
176
221
|
if (turnInterrupted)
|
|
177
222
|
return;
|
|
178
223
|
turnInterrupted = true;
|
|
179
224
|
void run.interrupt();
|
|
180
|
-
model.writeLine(ui.warn(
|
|
225
|
+
model.writeLine(ui.warn(t().interrupted));
|
|
181
226
|
}
|
|
182
|
-
// One reader
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
227
|
+
// One reader per session instead of drainTurn()'s one per turn: the prompt suggestion
|
|
228
|
+
// arrives after its turn's turn-end, while the human is already at the prompt, and MCP
|
|
229
|
+
// errors arrive before the first turn. Still manual .next() calls, never a for-await that
|
|
230
|
+
// could be broken out of (ADR-010). A reader left behind by /resume stops reporting.
|
|
186
231
|
let turnDone = null;
|
|
187
232
|
let readError = null;
|
|
188
233
|
function finishTurn() {
|
|
@@ -190,26 +235,48 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
190
235
|
turnDone = null;
|
|
191
236
|
done?.();
|
|
192
237
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
238
|
+
let queue = createInputQueue();
|
|
239
|
+
let run;
|
|
240
|
+
let knownCommands;
|
|
241
|
+
let generation = 0;
|
|
242
|
+
function connect() {
|
|
243
|
+
const own = ++generation;
|
|
244
|
+
queue = createInputQueue();
|
|
245
|
+
run = runQuery(queue.iterable, { ...current.options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
|
|
246
|
+
const events = run.events[Symbol.asyncIterator]();
|
|
247
|
+
input.setMode(modeControl()?.mode ?? null);
|
|
248
|
+
// Every registered command name and alias, built-ins included, for completion and for
|
|
249
|
+
// catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
|
|
250
|
+
knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
|
|
251
|
+
void knownCommands.then((names) => {
|
|
252
|
+
const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
|
|
253
|
+
const local = [...(tuiOptions.terminalIntegration === false ? [] : ["copy"]), ...(runsDir ? ["resume"] : [])];
|
|
254
|
+
input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
|
|
255
|
+
});
|
|
256
|
+
void (async () => {
|
|
257
|
+
try {
|
|
258
|
+
while (true) {
|
|
259
|
+
const { value, done } = await events.next();
|
|
260
|
+
if (own !== generation || done)
|
|
261
|
+
break;
|
|
262
|
+
if (value.type === "prompt-suggestion") {
|
|
263
|
+
input.setSuggestion(value.suggestion);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
model.render(value);
|
|
267
|
+
if (value.type === "turn-end")
|
|
268
|
+
finishTurn();
|
|
202
269
|
}
|
|
203
|
-
model.render(value);
|
|
204
|
-
if (value.type === "turn-end")
|
|
205
|
-
finishTurn();
|
|
206
270
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (own === generation)
|
|
273
|
+
readError = error;
|
|
274
|
+
}
|
|
275
|
+
if (own === generation)
|
|
276
|
+
finishTurn();
|
|
277
|
+
})();
|
|
278
|
+
}
|
|
279
|
+
connect();
|
|
213
280
|
async function runTurn(line) {
|
|
214
281
|
input.setSuggestion(null);
|
|
215
282
|
status?.turnStarted();
|
|
@@ -225,22 +292,59 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
225
292
|
if (readError)
|
|
226
293
|
throw readError;
|
|
227
294
|
}
|
|
295
|
+
/** Draws the run's earlier conversation (when it has one) and says it was resumed. */
|
|
296
|
+
async function showConversation(folder, fromResume) {
|
|
297
|
+
if (!folder.sessionId)
|
|
298
|
+
return;
|
|
299
|
+
const messages = await readConversation(folder.dir);
|
|
300
|
+
const [summary] = (await listRuns(path.dirname(folder.dir))).filter((r) => path.resolve(r.dir) === path.resolve(folder.dir));
|
|
301
|
+
// In full screen the history is redrawn from the start; inline, the terminal's
|
|
302
|
+
// scrollback can't be taken back, so the conversation follows what's already there.
|
|
303
|
+
if (fromResume && tuiOptions.fullscreen)
|
|
304
|
+
model.reset();
|
|
305
|
+
model.replay(messages, userLine);
|
|
306
|
+
model.writeLine(ui.dim(t().resumed(runDate(summary?.updatedAt ?? new Date()))));
|
|
307
|
+
}
|
|
308
|
+
/** /resume: picks one of the runs and switches the session to it. */
|
|
309
|
+
async function resume() {
|
|
310
|
+
if (!opener || !runsDir)
|
|
311
|
+
return;
|
|
312
|
+
const runs = await listRuns(runsDir);
|
|
313
|
+
if (runs.length === 0) {
|
|
314
|
+
model.writeLine(ui.dim(t().noEarlierRuns));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const chosen = await input.pick(t().resumeTitle, runs.map((summary, i) => ({ label: `${i + 1}. ${runLabel(summary, current.run ?? undefined)}`, value: summary.dir })));
|
|
318
|
+
const summary = runs.find((r) => r.dir === chosen);
|
|
319
|
+
if (!summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
|
|
320
|
+
return;
|
|
321
|
+
queue.end();
|
|
322
|
+
run.close();
|
|
323
|
+
current = await openSession(opener, await runFolderOf(summary));
|
|
324
|
+
openLog();
|
|
325
|
+
connect();
|
|
326
|
+
await showConversation(current.run, true);
|
|
327
|
+
}
|
|
228
328
|
const previousPort = getInteractionPort();
|
|
229
329
|
setInteractionPort(interaction.port);
|
|
230
330
|
const restoreTerminal = tuiOptions.fullscreen ? enterFullscreen(stdout) : null;
|
|
231
331
|
const cursor = tuiOptions.fullscreen ? createCursorController(stdout) : null;
|
|
232
332
|
const status = tuiOptions.terminalIntegration === false ? null : createTerminalStatus((text) => void stdout.write(text), tuiOptions.header?.title ?? "agent");
|
|
233
333
|
status?.start();
|
|
234
|
-
const app = render(_jsx(CursorContext.Provider, { value: cursor, children: _jsx(ChatApp, { model: model, interaction: interaction, input: input, promptLabel: promptLabel, renderApproval: tuiOptions.renderApproval, mode: tuiOptions.mode, fullscreen: tuiOptions.fullscreen, header: tuiOptions.header && tuiOptions.fullscreen ? headerLines(tuiOptions.header) : undefined, onFocusChange: status ? (focused) => status.setFocused(focused) : undefined, modeControl:
|
|
334
|
+
const app = render(_jsx(CursorContext.Provider, { value: cursor, children: _jsx(ChatApp, { model: model, interaction: interaction, input: input, promptLabel: promptLabel, renderApproval: tuiOptions.renderApproval, mode: tuiOptions.mode, fullscreen: tuiOptions.fullscreen, header: tuiOptions.header && tuiOptions.fullscreen ? headerLines(tuiOptions.header) : undefined, onFocusChange: status ? (focused) => status.setFocused(focused) : undefined, modeControl: modeControl, onInterrupt: interruptTurn }) }),
|
|
235
335
|
// In full screen Ink writes through the cursor controller, which places the terminal's cursor.
|
|
236
336
|
{ exitOnCtrlC: false, ...(cursor ? { stdout: cursor.stream } : {}) });
|
|
237
337
|
try {
|
|
238
338
|
// In full screen the header is pinned above the history (see SessionView) instead.
|
|
239
339
|
if (tuiOptions.header && !tuiOptions.fullscreen)
|
|
240
340
|
model.note(headerLines(tuiOptions.header).join("\n"));
|
|
341
|
+
for (const warning of languageWarnings)
|
|
342
|
+
model.writeLine(ui.warn(warning));
|
|
241
343
|
if (tuiOptions.welcomeMessage)
|
|
242
344
|
model.writeLine(tuiOptions.welcomeMessage);
|
|
243
|
-
if (
|
|
345
|
+
if (current.run)
|
|
346
|
+
await showConversation(current.run, false);
|
|
347
|
+
if (tuiOptions.initialPrompt && !current.run?.sessionId) {
|
|
244
348
|
// Logged but not shown, like runChatTui(): it is the agent's cue, not something typed.
|
|
245
349
|
mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
|
|
246
350
|
await runTurn(tuiOptions.initialPrompt);
|
|
@@ -256,7 +360,7 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
256
360
|
if (!line)
|
|
257
361
|
continue;
|
|
258
362
|
mirror(`${promptLabel}${line}\n`);
|
|
259
|
-
model.note(
|
|
363
|
+
model.note(userLine(line), "user");
|
|
260
364
|
if (tuiOptions.historyPath) {
|
|
261
365
|
historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
|
|
262
366
|
await saveHistory(tuiOptions.historyPath, historyEntries);
|
|
@@ -268,14 +372,18 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
268
372
|
const reply = model.lastReply();
|
|
269
373
|
if (reply)
|
|
270
374
|
stdout.write(clipboardSequence(reply));
|
|
271
|
-
model.writeLine(ui.dim(reply ?
|
|
375
|
+
model.writeLine(ui.dim(reply ? t().copiedReply(reply.length) : t().nothingToCopy));
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (runsDir && line.toLowerCase() === "/resume") {
|
|
379
|
+
await resume();
|
|
272
380
|
continue;
|
|
273
381
|
}
|
|
274
382
|
const commandToken = slashCommandToken(line);
|
|
275
383
|
if (commandToken) {
|
|
276
384
|
const names = await knownCommands;
|
|
277
385
|
if (names && !names.has(commandToken)) {
|
|
278
|
-
model.writeLine(ui.warn(
|
|
386
|
+
model.writeLine(ui.warn(t().unknownCommand(commandToken)));
|
|
279
387
|
continue;
|
|
280
388
|
}
|
|
281
389
|
}
|
|
@@ -71,6 +71,16 @@ export interface SessionModel {
|
|
|
71
71
|
/** The agent's text in the latest turn, plain (for /copy). */
|
|
72
72
|
lastReply(): string;
|
|
73
73
|
setContextPercent(percent: number | null): void;
|
|
74
|
+
/** Empties the history and the counters, for a resumed conversation redrawn from the start. */
|
|
75
|
+
reset(): void;
|
|
76
|
+
/**
|
|
77
|
+
* Draws an earlier conversation (a resumed one): the human's lines as `userLine()` makes
|
|
78
|
+
* them and the agent's replies with their markdown, without passing them to `onWrite`.
|
|
79
|
+
*/
|
|
80
|
+
replay(messages: readonly {
|
|
81
|
+
role: "user" | "assistant";
|
|
82
|
+
text: string;
|
|
83
|
+
}[], userLine: (text: string) => string): void;
|
|
74
84
|
}
|
|
75
85
|
/**
|
|
76
86
|
* The state behind the Ink views, in the look of the Claude Code CLI: the agent's words
|
|
@@ -4,6 +4,7 @@ import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
|
4
4
|
import { finishedLength, renderMarkdown } from "./markdown.js";
|
|
5
5
|
import { toolGroupExpanded, toolGroupSummary } from "./toolGroup.js";
|
|
6
6
|
import * as ui from "../ui.js";
|
|
7
|
+
import { t } from "../../core/messages/index.js";
|
|
7
8
|
/**
|
|
8
9
|
* Columns the live area may use: short of the edge, where the Windows console wraps early
|
|
9
10
|
* (see lineBuffer.ts). Read on every use, so a resized terminal is picked up.
|
|
@@ -196,7 +197,7 @@ export function createSessionModel(options = {}) {
|
|
|
196
197
|
}
|
|
197
198
|
case "mcp-error":
|
|
198
199
|
closeAll();
|
|
199
|
-
push([ui.warn(
|
|
200
|
+
push([ui.warn(t().mcpFailed(event.failedServers.join(", ")))], "notice");
|
|
200
201
|
refreshLive();
|
|
201
202
|
return;
|
|
202
203
|
case "info":
|
|
@@ -242,7 +243,7 @@ export function createSessionModel(options = {}) {
|
|
|
242
243
|
const started = snapshot.turnStartedAt;
|
|
243
244
|
if (started !== null) {
|
|
244
245
|
const seconds = Math.max(1, Math.round((Date.now() - started) / 1000));
|
|
245
|
-
push([ui.dim(`✻
|
|
246
|
+
push([ui.dim(`✻ ${t().workedFor(seconds)}`)], "note");
|
|
246
247
|
}
|
|
247
248
|
snapshot = { ...snapshot, busy: false, turnStartedAt: null, activity: null, subagentActivity: null };
|
|
248
249
|
refreshLive();
|
|
@@ -258,5 +259,29 @@ export function createSessionModel(options = {}) {
|
|
|
258
259
|
getSnapshot: () => snapshot,
|
|
259
260
|
lastReply: () => reply.trim(),
|
|
260
261
|
setContextPercent: (percent) => update({ contextPercent: percent }),
|
|
262
|
+
reset() {
|
|
263
|
+
closeAll();
|
|
264
|
+
lastKind = null;
|
|
265
|
+
gapPending = false;
|
|
266
|
+
reply = "";
|
|
267
|
+
snapshot = { ...snapshot, items: [], live: "", liveGap: false, turns: 0, usage: null, contextPercent: null };
|
|
268
|
+
refreshLive();
|
|
269
|
+
},
|
|
270
|
+
replay(messages, userLine) {
|
|
271
|
+
closeAll();
|
|
272
|
+
for (const message of messages) {
|
|
273
|
+
if (!lastIsBlank(snapshot.items))
|
|
274
|
+
gapPending = true;
|
|
275
|
+
if (message.role === "user") {
|
|
276
|
+
push(userLine(message.text).split("\n"), "user");
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
segment = message.text;
|
|
280
|
+
flushSegment(true);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
snapshot = { ...snapshot, turns: snapshot.turns + messages.filter((message) => message.role === "user").length };
|
|
284
|
+
refreshLive();
|
|
285
|
+
},
|
|
261
286
|
};
|
|
262
287
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ToolPhrase } from "../../core/messages/index.js";
|
|
1
2
|
export interface ToolCall {
|
|
2
3
|
id?: string;
|
|
3
4
|
toolName: string;
|
|
@@ -11,7 +12,7 @@ export interface ToolCall {
|
|
|
11
12
|
children?: string[];
|
|
12
13
|
}
|
|
13
14
|
/** How a tool counts in a group's summary: the phrase for one call and for several, "{n}" standing for the count. */
|
|
14
|
-
export type ToolPhrase
|
|
15
|
+
export type { ToolPhrase };
|
|
15
16
|
/**
|
|
16
17
|
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
17
18
|
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
@@ -1,24 +1,11 @@
|
|
|
1
1
|
import { fitWidth } from "./lineBuffer.js";
|
|
2
2
|
import { renderMarkdown } from "./markdown.js";
|
|
3
3
|
import * as ui from "../ui.js";
|
|
4
|
+
import { t } from "../../core/messages/index.js";
|
|
4
5
|
// A subagent can make many calls; the latest ones are shown, the rest counted.
|
|
5
6
|
const MAX_CHILDREN = 5;
|
|
6
7
|
// Tools whose result is a subagent's answer, in markdown: its first line is rendered.
|
|
7
8
|
const SUBAGENT_TOOLS = new Set(["Agent", "Task"]);
|
|
8
|
-
const BUILT_IN = {
|
|
9
|
-
Read: ["read {n} file", "read {n} files"],
|
|
10
|
-
Write: ["wrote {n} file", "wrote {n} files"],
|
|
11
|
-
Edit: ["edited {n} file", "edited {n} files"],
|
|
12
|
-
Glob: ["listed files", "listed files {n} times"],
|
|
13
|
-
Grep: ["searched {n} time", "searched {n} times"],
|
|
14
|
-
Bash: ["ran {n} shell command", "ran {n} shell commands"],
|
|
15
|
-
WebSearch: ["searched the web", "searched the web {n} times"],
|
|
16
|
-
WebFetch: ["fetched {n} page", "fetched {n} pages"],
|
|
17
|
-
Skill: ["used {n} skill", "used {n} skills"],
|
|
18
|
-
Agent: ["ran {n} subagent", "ran {n} subagents"],
|
|
19
|
-
Task: ["ran {n} subagent", "ran {n} subagents"],
|
|
20
|
-
};
|
|
21
|
-
const OTHER = ["used {n} tool", "used {n} tools"];
|
|
22
9
|
/**
|
|
23
10
|
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
24
11
|
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
@@ -29,7 +16,7 @@ export function toolGroupSummary(calls, phraseFor) {
|
|
|
29
16
|
// Counted by the phrase's text: a consumer's phraseFor() may return a new array each call.
|
|
30
17
|
const counts = new Map();
|
|
31
18
|
for (const call of calls) {
|
|
32
|
-
const phrase = phraseFor?.(call.toolName) ??
|
|
19
|
+
const phrase = phraseFor?.(call.toolName) ?? t().toolPhrases[call.toolName] ?? t().otherTools;
|
|
33
20
|
const key = phrase.join("|");
|
|
34
21
|
const entry = counts.get(key) ?? { phrase, n: 0 };
|
|
35
22
|
entry.n++;
|
|
@@ -46,8 +33,8 @@ export function toolGroupSummary(calls, phraseFor) {
|
|
|
46
33
|
export function resultSummary(result, markdown = false) {
|
|
47
34
|
const lines = result.text.split("\n").filter((line) => line.trim() !== "");
|
|
48
35
|
if (lines.length === 0)
|
|
49
|
-
return result.isError ? ui.error(
|
|
50
|
-
const more = lines.length > 1 ? ui.dim(` (
|
|
36
|
+
return result.isError ? ui.error(t().error) : ui.dim(t().noOutput);
|
|
37
|
+
const more = lines.length > 1 ? ui.dim(` ${t().moreResultLines(lines.length - 1)}`) : "";
|
|
51
38
|
const first = lines[0].trim();
|
|
52
39
|
if (result.isError)
|
|
53
40
|
return ui.error(first) + more;
|
|
@@ -65,7 +52,7 @@ export function toolGroupExpanded(calls, width) {
|
|
|
65
52
|
const shown = children.slice(-MAX_CHILDREN);
|
|
66
53
|
const result = call.result ? fitWidth(resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName)), width - 5) : ui.dim("…");
|
|
67
54
|
const under = [
|
|
68
|
-
...(children.length > shown.length ? [ui.dim(
|
|
55
|
+
...(children.length > shown.length ? [ui.dim(t().earlierCalls(children.length - shown.length))] : []),
|
|
69
56
|
...shown.map((label) => fitWidth(ui.dim(`· ${label}`), width - 5)),
|
|
70
57
|
result,
|
|
71
58
|
];
|
package/dist/tui/ink/wizard.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface WizardOptions {
|
|
|
37
37
|
title?: string;
|
|
38
38
|
/** Use the plain @inquirer/prompts questions even on a TTY. */
|
|
39
39
|
plain?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The language of the kit's texts ("en", "es", "fr", "de"). `--language=<code>` on the
|
|
42
|
+
* command line wins; without either, the one `buildSessionOptions()` chose from
|
|
43
|
+
* `config.language`, or else the system's.
|
|
44
|
+
*/
|
|
45
|
+
language?: string;
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* How many choices a select shows at once, so the question, its choices and a validation
|
package/dist/tui/ink/wizard.js
CHANGED
|
@@ -7,6 +7,8 @@ import { liveWidth } from "./sessionModel.js";
|
|
|
7
7
|
import { ConfirmInput, PasswordInput, Select, TextInput } from "@inkjs/ui";
|
|
8
8
|
import * as inquirer from "@inquirer/prompts";
|
|
9
9
|
import * as ui from "../ui.js";
|
|
10
|
+
import { t } from "../../core/messages/index.js";
|
|
11
|
+
import { applyLanguage } from "../language.js";
|
|
10
12
|
function resolve(value, answers) {
|
|
11
13
|
return typeof value === "function" ? value(answers) : value;
|
|
12
14
|
}
|
|
@@ -63,7 +65,7 @@ function summary(step, value, answers) {
|
|
|
63
65
|
if (step.type === "password")
|
|
64
66
|
return "*".repeat(String(value).length);
|
|
65
67
|
if (step.type === "confirm")
|
|
66
|
-
return value ?
|
|
68
|
+
return value ? t().yes : t().no;
|
|
67
69
|
if (step.type === "select")
|
|
68
70
|
return resolve(step.choices, answers).find((c) => c.value === value)?.name ?? String(value);
|
|
69
71
|
return String(value);
|
|
@@ -154,6 +156,7 @@ async function runPlainWizard(steps, title) {
|
|
|
154
156
|
* steps through @inquirer/prompts.
|
|
155
157
|
*/
|
|
156
158
|
export async function runWizard(steps, options = {}) {
|
|
159
|
+
applyLanguage(options.language);
|
|
157
160
|
if (options.plain || !stdin.isTTY || !stdout.isTTY)
|
|
158
161
|
return await runPlainWizard(steps, options.title);
|
|
159
162
|
if (nextIndex(steps, -1, {}) >= steps.length)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chooses the kit's language for a terminal entry point (see messages/'s chooseLanguage())
|
|
3
|
+
* and prints the warnings about unsupported codes not shown yet, or hands them to `show`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function applyLanguage(option: string | undefined, show?: (line: string) => void): void;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { chooseLanguage, takeLanguageWarnings } from "../core/messages/index.js";
|
|
2
|
+
import * as ui from "./ui.js";
|
|
3
|
+
/**
|
|
4
|
+
* Chooses the kit's language for a terminal entry point (see messages/'s chooseLanguage())
|
|
5
|
+
* and prints the warnings about unsupported codes not shown yet, or hands them to `show`.
|
|
6
|
+
*/
|
|
7
|
+
export function applyLanguage(option, show = (line) => console.warn(ui.warn(line))) {
|
|
8
|
+
chooseLanguage(option);
|
|
9
|
+
for (const warning of takeLanguageWarnings())
|
|
10
|
+
show(warning);
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { ModeControl } from "../core/session.js";
|
|
3
|
+
import { type RunFolder, type RunSummary } from "../core/runs.js";
|
|
4
|
+
/**
|
|
5
|
+
* Builds the session's options for a run folder, e.g.
|
|
6
|
+
* `(run) => buildSessionOptions(config, run.dir, spec, { run })`: called for the first run
|
|
7
|
+
* and again for each one resumed with /resume (MCP servers, the step gate and the transcript
|
|
8
|
+
* are bound to the run's folder, so they're built anew).
|
|
9
|
+
*/
|
|
10
|
+
export type SessionOpener = (run: RunFolder) => Promise<Options | {
|
|
11
|
+
options: Options;
|
|
12
|
+
modeControl?: ModeControl;
|
|
13
|
+
}>;
|
|
14
|
+
export interface OpenedSession {
|
|
15
|
+
run: RunFolder;
|
|
16
|
+
options: Options;
|
|
17
|
+
modeControl?: ModeControl;
|
|
18
|
+
}
|
|
19
|
+
/** The run a chat starts with: the latest one with `--continue` (if any), a new one otherwise. */
|
|
20
|
+
export declare function firstRun(runsDir: string, argv?: readonly string[]): Promise<RunFolder>;
|
|
21
|
+
export declare function openSession(opener: SessionOpener, run: RunFolder): Promise<OpenedSession>;
|
|
22
|
+
/** A run's date for a person, in the kit's language. */
|
|
23
|
+
export declare function runDate(date: Date): string;
|
|
24
|
+
/** A run in the /resume list: its date and the human's last message. */
|
|
25
|
+
export declare function runLabel(run: RunSummary, current?: RunFolder): string;
|
|
26
|
+
/** The run folder of a summary, re-reading its session (a run resumed a moment ago may have changed). */
|
|
27
|
+
export declare function runFolderOf(run: RunSummary): Promise<RunFolder>;
|
package/dist/tui/runs.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { continueArgument, createRunFolder, listRuns, readRunSession } from "../core/runs.js";
|
|
3
|
+
import { getLanguage, t } from "../core/messages/index.js";
|
|
4
|
+
/** The run a chat starts with: the latest one with `--continue` (if any), a new one otherwise. */
|
|
5
|
+
export async function firstRun(runsDir, argv = process.argv.slice(2)) {
|
|
6
|
+
if (continueArgument(argv)) {
|
|
7
|
+
const [latest] = await listRuns(runsDir);
|
|
8
|
+
if (latest)
|
|
9
|
+
return { dir: latest.dir, sessionId: latest.sessionId };
|
|
10
|
+
}
|
|
11
|
+
return await createRunFolder(runsDir);
|
|
12
|
+
}
|
|
13
|
+
export async function openSession(opener, run) {
|
|
14
|
+
const opened = await opener(run);
|
|
15
|
+
// The SDK's Options has no "options" field: this is buildSessionOptions()'s result.
|
|
16
|
+
return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl } : { run, options: opened };
|
|
17
|
+
}
|
|
18
|
+
/** A run's date for a person, in the kit's language. */
|
|
19
|
+
export function runDate(date) {
|
|
20
|
+
return new Intl.DateTimeFormat(getLanguage(), { dateStyle: "medium", timeStyle: "short" }).format(date);
|
|
21
|
+
}
|
|
22
|
+
/** A run in the /resume list: its date and the human's last message. */
|
|
23
|
+
export function runLabel(run, current) {
|
|
24
|
+
const mark = current && path.resolve(current.dir) === path.resolve(run.dir) ? ` ${t().currentRun}` : "";
|
|
25
|
+
return `${runDate(run.updatedAt)}${mark}${run.lastMessage ? ` · ${run.lastMessage}` : ""}`;
|
|
26
|
+
}
|
|
27
|
+
/** The run folder of a summary, re-reading its session (a run resumed a moment ago may have changed). */
|
|
28
|
+
export async function runFolderOf(run) {
|
|
29
|
+
return { dir: run.dir, sessionId: (await readRunSession(run.dir)) ?? run.sessionId };
|
|
30
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import readline from "node:readline/promises";
|
|
2
2
|
import { stdin, stdout } from "node:process";
|
|
3
|
-
|
|
3
|
+
import { t } from "../core/messages/index.js";
|
|
4
4
|
/**
|
|
5
5
|
* A long-lived chat REPL's readline interface (if any), so that the terminal port reuses
|
|
6
6
|
* it instead of creating its own.
|
|
@@ -55,7 +55,7 @@ async function askOnTerminal(prompt, signal) {
|
|
|
55
55
|
console.log(`\n=== ${prompt.title} ===`);
|
|
56
56
|
for (const line of prompt.lines)
|
|
57
57
|
console.log(line);
|
|
58
|
-
const question = prompt.question ??
|
|
58
|
+
const question = prompt.question ?? t().terminalQuestion;
|
|
59
59
|
const shared = sharedReadline;
|
|
60
60
|
if (shared)
|
|
61
61
|
return await askOnSharedReadline(shared, question);
|