@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.
Files changed (51) hide show
  1. package/README.md +91 -97
  2. package/dist/core/agentSpec.d.ts +31 -1
  3. package/dist/core/hooks/stepGate.js +3 -2
  4. package/dist/core/language.d.ts +39 -0
  5. package/dist/core/language.js +59 -0
  6. package/dist/core/messages/de.d.ts +2 -0
  7. package/dist/core/messages/de.js +103 -0
  8. package/dist/core/messages/en.d.ts +90 -0
  9. package/dist/core/messages/en.js +102 -0
  10. package/dist/core/messages/es.d.ts +2 -0
  11. package/dist/core/messages/es.js +103 -0
  12. package/dist/core/messages/fr.d.ts +2 -0
  13. package/dist/core/messages/fr.js +103 -0
  14. package/dist/core/messages/index.d.ts +23 -0
  15. package/dist/core/messages/index.js +67 -0
  16. package/dist/core/runs.d.ts +51 -0
  17. package/dist/core/runs.js +194 -0
  18. package/dist/core/session.d.ts +8 -0
  19. package/dist/core/session.js +42 -7
  20. package/dist/core/toolLabels.d.ts +2 -1
  21. package/dist/core/toolLabels.js +31 -51
  22. package/dist/core/tools/humanApproval.js +3 -2
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +6 -0
  25. package/dist/tui/chatTui.d.ts +23 -2
  26. package/dist/tui/chatTui.js +71 -7
  27. package/dist/tui/claudeAuth.d.ts +3 -1
  28. package/dist/tui/claudeAuth.js +12 -17
  29. package/dist/tui/consoleRenderer.js +2 -1
  30. package/dist/tui/ink/PromptInput.d.ts +0 -2
  31. package/dist/tui/ink/PromptInput.js +5 -15
  32. package/dist/tui/ink/SessionView.js +16 -15
  33. package/dist/tui/ink/inkInteraction.js +4 -7
  34. package/dist/tui/ink/progressView.d.ts +6 -0
  35. package/dist/tui/ink/progressView.js +2 -0
  36. package/dist/tui/ink/promptEditing.d.ts +2 -5
  37. package/dist/tui/ink/promptEditing.js +11 -3
  38. package/dist/tui/ink/runChatInk.d.ts +25 -2
  39. package/dist/tui/ink/runChatInk.js +156 -48
  40. package/dist/tui/ink/sessionModel.d.ts +10 -0
  41. package/dist/tui/ink/sessionModel.js +27 -2
  42. package/dist/tui/ink/toolGroup.d.ts +2 -1
  43. package/dist/tui/ink/toolGroup.js +5 -18
  44. package/dist/tui/ink/wizard.d.ts +6 -0
  45. package/dist/tui/ink/wizard.js +4 -1
  46. package/dist/tui/language.d.ts +5 -0
  47. package/dist/tui/language.js +11 -0
  48. package/dist/tui/runs.d.ts +27 -0
  49. package/dist/tui/runs.js +30 -0
  50. package/dist/tui/terminalInteraction.js +2 -2
  51. 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
- function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl, onInterrupt }) {
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(`(${modeControl?.mode ?? mode ?? "this"} mode can't be switched during the session)`), "notice");
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(` queued: ${line}`) }, index))), _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) }) })] }) }));
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
- const queue = createInputQueue();
148
- const run = runQuery(queue.iterable, { ...options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
149
- const events = run.events[Symbol.asyncIterator]();
150
- const sessionLog = tuiOptions.sessionLogPath ? createWriteStream(tuiOptions.sessionLogPath, { flags: "a" }) : null;
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
- if (tuiOptions.modeControl)
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("(interrupted)"));
225
+ model.writeLine(ui.warn(t().interrupted));
181
226
  }
182
- // One reader for the whole session instead of drainTurn()'s one per turn: the prompt
183
- // suggestion arrives after its turn's turn-end, while the human is already at the prompt,
184
- // and MCP errors arrive before the first turn. Still manual .next() calls, never a
185
- // for-await that could be broken out of (ADR-010).
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
- void (async () => {
194
- try {
195
- while (true) {
196
- const { value, done } = await events.next();
197
- if (done)
198
- break;
199
- if (value.type === "prompt-suggestion") {
200
- input.setSuggestion(value.suggestion);
201
- continue;
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
- catch (error) {
209
- readError = error;
210
- }
211
- finishTurn();
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: tuiOptions.modeControl, onInterrupt: interruptTurn }) }),
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 (tuiOptions.initialPrompt) {
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(`${promptLabel.replace(/^\n+/, "")}${line}`, "user");
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 ? `(copied the last reply: ${reply.length} characters)` : "(nothing to copy yet)"));
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(`Unknown command: /${commandToken}`));
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(`Some MCP servers failed to connect: ${event.failedServers.join(", ")}`)], "notice");
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(`✻ Worked for ${seconds}s`)], "note");
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 = [one: string, many: string];
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) ?? BUILT_IN[call.toolName] ?? OTHER;
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("error") : ui.dim("(no output)");
50
- const more = lines.length > 1 ? ui.dim(` (+${lines.length - 1} ${lines.length === 2 ? "line" : "lines"})`) : "";
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(`… ${children.length - shown.length} earlier`)] : []),
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
  ];
@@ -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
@@ -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 ? "Yes" : "No";
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>;
@@ -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
- const DEFAULT_QUESTION = "Allow this to continue? [Y/n/q] ";
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 ?? DEFAULT_QUESTION;
58
+ const question = prompt.question ?? t().terminalQuestion;
59
59
  const shared = sharedReadline;
60
60
  if (shared)
61
61
  return await askOnSharedReadline(shared, question);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "description": "Library for building an agent on top of the Claude Agent SDK",
5
5
  "author": "Francisco Vargas Ruiz",
6
6
  "license": "MIT",