@falkenslab/agent-kit 0.10.2 → 0.13.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 (68) hide show
  1. package/README.md +105 -97
  2. package/dist/core/agentSpec.d.ts +31 -1
  3. package/dist/core/claudeAuth.d.ts +1 -0
  4. package/dist/core/hooks/stepGate.js +3 -2
  5. package/dist/core/hooks/transcriptLogger.d.ts +1 -0
  6. package/dist/core/interaction.d.ts +3 -0
  7. package/dist/core/interaction.js +2 -0
  8. package/dist/core/language.d.ts +41 -0
  9. package/dist/core/language.js +60 -0
  10. package/dist/core/messages/de.d.ts +2 -0
  11. package/dist/core/messages/de.js +103 -0
  12. package/dist/core/messages/en.d.ts +90 -0
  13. package/dist/core/messages/en.js +102 -0
  14. package/dist/core/messages/es.d.ts +2 -0
  15. package/dist/core/messages/es.js +103 -0
  16. package/dist/core/messages/fr.d.ts +2 -0
  17. package/dist/core/messages/fr.js +103 -0
  18. package/dist/core/messages/index.d.ts +23 -0
  19. package/dist/core/messages/index.js +67 -0
  20. package/dist/core/runner.d.ts +2 -0
  21. package/dist/core/runs.d.ts +53 -0
  22. package/dist/core/runs.js +194 -0
  23. package/dist/core/session.d.ts +9 -0
  24. package/dist/core/session.js +43 -7
  25. package/dist/core/toolLabels.d.ts +8 -1
  26. package/dist/core/toolLabels.js +36 -54
  27. package/dist/core/tools/humanApproval.d.ts +1 -0
  28. package/dist/core/tools/humanApproval.js +3 -2
  29. package/dist/core/tools/manualLogin.d.ts +1 -0
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +7 -0
  32. package/dist/tui/chatTui.d.ts +31 -2
  33. package/dist/tui/chatTui.js +73 -7
  34. package/dist/tui/claudeAuth.d.ts +3 -1
  35. package/dist/tui/claudeAuth.js +12 -17
  36. package/dist/tui/consoleRenderer.d.ts +2 -0
  37. package/dist/tui/consoleRenderer.js +2 -1
  38. package/dist/tui/ink/PromptInput.d.ts +0 -2
  39. package/dist/tui/ink/PromptInput.js +5 -15
  40. package/dist/tui/ink/SessionView.js +17 -16
  41. package/dist/tui/ink/header.d.ts +1 -0
  42. package/dist/tui/ink/inkInteraction.js +4 -7
  43. package/dist/tui/ink/inkTheme.d.ts +9 -0
  44. package/dist/tui/ink/inkTheme.js +25 -0
  45. package/dist/tui/ink/markdown.js +49 -8
  46. package/dist/tui/ink/progressView.d.ts +15 -0
  47. package/dist/tui/ink/progressView.js +6 -1
  48. package/dist/tui/ink/promptEditing.d.ts +2 -5
  49. package/dist/tui/ink/promptEditing.js +11 -3
  50. package/dist/tui/ink/runChatInk.d.ts +26 -2
  51. package/dist/tui/ink/runChatInk.js +159 -48
  52. package/dist/tui/ink/sessionModel.d.ts +10 -0
  53. package/dist/tui/ink/sessionModel.js +27 -2
  54. package/dist/tui/ink/toolGroup.d.ts +2 -1
  55. package/dist/tui/ink/toolGroup.js +10 -21
  56. package/dist/tui/ink/wizard.d.ts +20 -0
  57. package/dist/tui/ink/wizard.js +8 -2
  58. package/dist/tui/language.d.ts +5 -0
  59. package/dist/tui/language.js +11 -0
  60. package/dist/tui/runs.d.ts +27 -0
  61. package/dist/tui/runs.js +30 -0
  62. package/dist/tui/terminalInteraction.d.ts +2 -0
  63. package/dist/tui/terminalInteraction.js +4 -2
  64. package/dist/tui/theme.d.ts +60 -0
  65. package/dist/tui/theme.js +65 -0
  66. package/dist/tui/ui.d.ts +23 -3
  67. package/dist/tui/ui.js +42 -18
  68. package/package.json +79 -75
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Pure editing operations on the prompt's text and cursor (a UTF-16 index into `value`),
3
+ * so PromptInput.tsx only maps keys to them. The value may hold line breaks ("\n"): a
4
+ * multi-line prompt.
5
+ */
6
+ import { t } from "../../core/messages/index.js";
1
7
  export function insert({ value, cursor }, text) {
2
8
  return { value: value.slice(0, cursor) + text + value.slice(cursor), cursor: cursor + text.length };
3
9
  }
@@ -91,10 +97,12 @@ export function continueLine(state) {
91
97
  return null;
92
98
  return insert(deleteBack(state), "\n");
93
99
  }
94
- const PASTE_TOKEN = /\[Pasted text #(\d+) \+\d+ lines?\]/g;
100
+ // A paste token in any language ("[Pasted text #1 +2 lines]", "[Texto pegado #1 +2 líneas]"...).
101
+ const PASTE_TOKEN = /\[[^\]\n#]+#(\d+) \+\d+ [^\]\n]+\]/g;
102
+ export const PASTE_TOKEN_BEFORE = /\[[^\]\n#]+#\d+ \+\d+ [^\]\n]+\]$/;
103
+ export const PASTE_TOKEN_AFTER = /^\[[^\]\n#]+#\d+ \+\d+ [^\]\n]+\]/;
95
104
  function pasteToken(id, text) {
96
- const extra = text.split("\n").length - 1;
97
- return `[Pasted text #${id} +${extra} ${extra === 1 ? "line" : "lines"}]`;
105
+ return t().pastedText(id, text.split("\n").length - 1);
98
106
  }
99
107
  /** Normalizes a pasted chunk's line breaks (terminals send "\r" or "\r\n"). */
100
108
  export function normalizePaste(text) {
@@ -2,10 +2,12 @@ import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { Mode } from "../../core/agentSpec.js";
3
3
  import { type ModeControl } from "../../core/session.js";
4
4
  import { type ChatTuiOptions } from "../chatTui.js";
5
+ import { type SessionOpener } from "../runs.js";
5
6
  import { type RenderApproval } from "./SessionView.js";
6
7
  import type { ToolPhrase } from "./toolGroup.js";
7
8
  import { type HeaderInfo } from "./header.js";
8
9
  export type { HeaderInfo } from "./header.js";
10
+ /** Options of `runChatInk()`: the chat's options plus the Ink-only ones. */
9
11
  export interface InkChatOptions extends ChatTuiOptions {
10
12
  /**
11
13
  * Title, fields and an optional logo for the top of the chat: fixed at the top in full
@@ -46,7 +48,8 @@ export interface InkChatOptions extends ChatTuiOptions {
46
48
  /**
47
49
  * The session's mode control (`buildSessionOptions()` returns it): the status bar shows the
48
50
  * current mode, and Shift+Tab switches between the modes it allows ("guided" and
49
- * "interactive"; an autonomous session can't switch).
51
+ * "interactive"; an autonomous session can't switch). With a session opener, the one it
52
+ * returns is used instead.
50
53
  */
51
54
  modeControl?: ModeControl;
52
55
  /**
@@ -55,6 +58,16 @@ export interface InkChatOptions extends ChatTuiOptions {
55
58
  */
56
59
  firstPromptSuggestion?: string;
57
60
  }
61
+ /** A choice asked in place of the prompt (the /resume list). */
62
+ export interface ChatPicker {
63
+ title: string;
64
+ options: {
65
+ label: string;
66
+ value: string;
67
+ }[];
68
+ /** Settles it with the chosen value, or null if cancelled. */
69
+ resolve(value: string | null): void;
70
+ }
58
71
  export interface ChatInputSnapshot {
59
72
  /** The chat loop is waiting for a line (no turn in flight). */
60
73
  prompting: boolean;
@@ -68,6 +81,8 @@ export interface ChatInputSnapshot {
68
81
  files: string[];
69
82
  /** The current mode, when a mode control is given. */
70
83
  mode: Mode | null;
84
+ /** A choice shown in place of the prompt, if any. */
85
+ picker: ChatPicker | null;
71
86
  closed: boolean;
72
87
  }
73
88
  /**
@@ -85,6 +100,11 @@ export declare function createChatInput(): {
85
100
  setSuggestion: (suggestion: string | null) => void;
86
101
  setFiles: (files: string[]) => void;
87
102
  setMode: (mode: Mode | null) => void;
103
+ /** Shows a choice in place of the prompt; resolves with the value chosen, or null if cancelled. */
104
+ pick(title: string, options: {
105
+ label: string;
106
+ value: string;
107
+ }[]): Promise<string | null>;
88
108
  /** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
89
109
  unqueueLast(): string | null;
90
110
  close: () => void;
@@ -98,6 +118,10 @@ export declare function createChatInput(): {
98
118
  *
99
119
  * With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
100
120
  *
121
+ * Given a session opener instead of options, and `runsDir`, each run keeps its conversation
122
+ * in its own folder and can be resumed: `--continue` on the command line starts with the
123
+ * latest, and `/resume` picks one (see `ChatTuiOptions.runsDir`).
124
+ *
101
125
  * Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
102
126
  */
103
- export declare function runChatInk(options: Options, tuiOptions?: InkChatOptions): Promise<void>;
127
+ export declare function runChatInk(options: Options | SessionOpener, tuiOptions?: InkChatOptions): Promise<void>;
@@ -1,18 +1,26 @@
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 { applyTheme, inkColor } from "../theme.js";
17
+ import { KitTheme } from "./inkTheme.js";
18
+ import { firstRun, openSession, runDate, runFolderOf, runLabel } from "../runs.js";
11
19
  import { createInkInteraction } from "./inkInteraction.js";
12
20
  import { PromptInput } from "./PromptInput.js";
13
21
  import { createSessionModel, liveWidth } from "./sessionModel.js";
14
22
  import { SessionView } from "./SessionView.js";
15
- import { stripAnsi } from "./lineBuffer.js";
23
+ import { fitWidth, stripAnsi } from "./lineBuffer.js";
16
24
  import { clipboardSequence, enterFullscreen } from "./fullscreen.js";
17
25
  import { createCursorController, CursorContext } from "./terminalCursor.js";
18
26
  import { createTerminalStatus, focusFromReport } from "./terminalStatus.js";
@@ -30,7 +38,7 @@ const PROMPT_FRAME_COLUMNS = 4;
30
38
  */
31
39
  export function createChatInput() {
32
40
  const listeners = new Set();
33
- let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, closed: false };
41
+ let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, picker: null, closed: false };
34
42
  let pending = null;
35
43
  let exited = false;
36
44
  function update(changes) {
@@ -74,6 +82,21 @@ export function createChatInput() {
74
82
  setSuggestion: (suggestion) => update({ suggestion }),
75
83
  setFiles: (files) => update({ files }),
76
84
  setMode: (mode) => update({ mode }),
85
+ /** Shows a choice in place of the prompt; resolves with the value chosen, or null if cancelled. */
86
+ pick(title, options) {
87
+ return new Promise((resolve) => {
88
+ update({
89
+ picker: {
90
+ title,
91
+ options,
92
+ resolve(value) {
93
+ update({ picker: null });
94
+ resolve(value);
95
+ },
96
+ },
97
+ });
98
+ });
99
+ },
77
100
  /** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
78
101
  unqueueLast() {
79
102
  const last = snapshot.queued.at(-1);
@@ -85,10 +108,21 @@ export function createChatInput() {
85
108
  close: () => update({ prompting: false, closed: true }),
86
109
  };
87
110
  }
88
- function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl, onInterrupt }) {
111
+ /** The /resume list, in place of the prompt: ↑/↓ and Enter choose, Esc cancels. */
112
+ function PickerPanel({ picker }) {
113
+ const { columns } = stdout;
114
+ useInput((_text, key) => {
115
+ if (key.escape)
116
+ picker.resolve(null);
117
+ });
118
+ const width = liveWidth(columns) - PROMPT_FRAME_COLUMNS;
119
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("border"), 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 })] }));
120
+ }
121
+ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl: currentModeControl, onInterrupt }) {
89
122
  const chat = useSyncExternalStore(input.subscribe, input.getSnapshot);
90
123
  const session = useSyncExternalStore(model.subscribe, model.getSnapshot);
91
124
  const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
125
+ const modeControl = currentModeControl();
92
126
  // Ctrl+C stops what is running (a checkpoint, then the turn) and leaves only when idle;
93
127
  // Esc interrupts the turn but never answers a checkpoint or leaves.
94
128
  useInput((text, key) => {
@@ -104,7 +138,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
104
138
  if (key.shift && key.tab) {
105
139
  // Shift+Tab: the next mode this session allows.
106
140
  if (!modeControl || modeControl.switchable.length < 2) {
107
- model.note(ui.dim(`(${modeControl?.mode ?? mode ?? "this"} mode can't be switched during the session)`), "notice");
141
+ model.note(ui.dim(t().modeLocked(modeControl?.mode ?? mode)), "notice");
108
142
  return;
109
143
  }
110
144
  const next = modeControl.switchable[(modeControl.switchable.indexOf(modeControl.mode) + 1) % modeControl.switchable.length];
@@ -112,6 +146,10 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
112
146
  input.setMode(next);
113
147
  return;
114
148
  }
149
+ if (key.ctrl && text === "c" && chat.picker) {
150
+ chat.picker.resolve(null);
151
+ return;
152
+ }
115
153
  if (key.ctrl && text === "c") {
116
154
  if (checkpoint)
117
155
  checkpoint.answer(checkpoint.kind === "decision" ? "q" : "");
@@ -124,7 +162,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
124
162
  onInterrupt();
125
163
  }
126
164
  });
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) }) })] }) }));
165
+ 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: inkColor("border"), 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
166
  }
129
167
  /**
130
168
  * The Ink counterpart of `runChatTui()`, with the same options, session log and history
@@ -135,19 +173,39 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
135
173
  *
136
174
  * With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
137
175
  *
176
+ * Given a session opener instead of options, and `runsDir`, each run keeps its conversation
177
+ * in its own folder and can be resumed: `--continue` on the command line starts with the
178
+ * latest, and `/resume` picks one (see `ChatTuiOptions.runsDir`).
179
+ *
138
180
  * Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
139
181
  */
140
182
  export async function runChatInk(options, tuiOptions = {}) {
141
183
  if (tuiOptions.plain || !stdin.isTTY || !stdout.isTTY)
142
184
  return await runChatTui(options, tuiOptions);
185
+ // Warnings about an unsupported language code go into the chat, not behind the full screen.
186
+ const languageWarnings = [];
187
+ applyLanguage(tuiOptions.language, (line) => languageWarnings.push(line));
188
+ applyTheme(tuiOptions.theme);
189
+ const opener = typeof options === "function" ? options : null;
190
+ const runsDir = opener ? tuiOptions.runsDir : undefined;
191
+ if (opener && !runsDir)
192
+ throw new Error("runChatInk(): a session opener needs `runsDir`.");
143
193
  const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
144
194
  const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
195
+ const userLine = (text) => `${promptLabel.replace(/^\n+/, "")}${text}`;
145
196
  const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
146
197
  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;
198
+ // The session in use: with runs, the run's folder, rebuilt on /resume.
199
+ let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
200
+ const modeControl = () => current.modeControl ?? tuiOptions.modeControl;
201
+ // With runs, the session log is the run's own; it moves with /resume.
202
+ let sessionLog = null;
203
+ function openLog() {
204
+ sessionLog?.end();
205
+ const file = current.run ? path.join(current.run.dir, "session.log") : tuiOptions.sessionLogPath;
206
+ sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
207
+ }
208
+ openLog();
151
209
  const mirror = (text) => void sessionLog?.write(stripAnsi(text));
152
210
  const model = createSessionModel({
153
211
  formatAction: tuiOptions.formatAction,
@@ -160,29 +218,19 @@ export async function runChatInk(options, tuiOptions = {}) {
160
218
  const input = createChatInput();
161
219
  if (tuiOptions.firstPromptSuggestion)
162
220
  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
- });
221
+ void listProjectFiles(current.options.cwd ?? process.cwd()).then((files) => input.setFiles(files));
174
222
  let turnInterrupted = false;
175
223
  function interruptTurn() {
176
224
  if (turnInterrupted)
177
225
  return;
178
226
  turnInterrupted = true;
179
227
  void run.interrupt();
180
- model.writeLine(ui.warn("(interrupted)"));
228
+ model.writeLine(ui.warn(t().interrupted));
181
229
  }
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).
230
+ // One reader per session instead of drainTurn()'s one per turn: the prompt suggestion
231
+ // arrives after its turn's turn-end, while the human is already at the prompt, and MCP
232
+ // errors arrive before the first turn. Still manual .next() calls, never a for-await that
233
+ // could be broken out of (ADR-010). A reader left behind by /resume stops reporting.
186
234
  let turnDone = null;
187
235
  let readError = null;
188
236
  function finishTurn() {
@@ -190,26 +238,48 @@ export async function runChatInk(options, tuiOptions = {}) {
190
238
  turnDone = null;
191
239
  done?.();
192
240
  }
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;
241
+ let queue = createInputQueue();
242
+ let run;
243
+ let knownCommands;
244
+ let generation = 0;
245
+ function connect() {
246
+ const own = ++generation;
247
+ queue = createInputQueue();
248
+ run = runQuery(queue.iterable, { ...current.options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
249
+ const events = run.events[Symbol.asyncIterator]();
250
+ input.setMode(modeControl()?.mode ?? null);
251
+ // Every registered command name and alias, built-ins included, for completion and for
252
+ // catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
253
+ knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
254
+ void knownCommands.then((names) => {
255
+ const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
256
+ const local = [...(tuiOptions.terminalIntegration === false ? [] : ["copy"]), ...(runsDir ? ["resume"] : [])];
257
+ input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
258
+ });
259
+ void (async () => {
260
+ try {
261
+ while (true) {
262
+ const { value, done } = await events.next();
263
+ if (own !== generation || done)
264
+ break;
265
+ if (value.type === "prompt-suggestion") {
266
+ input.setSuggestion(value.suggestion);
267
+ continue;
268
+ }
269
+ model.render(value);
270
+ if (value.type === "turn-end")
271
+ finishTurn();
202
272
  }
203
- model.render(value);
204
- if (value.type === "turn-end")
205
- finishTurn();
206
273
  }
207
- }
208
- catch (error) {
209
- readError = error;
210
- }
211
- finishTurn();
212
- })();
274
+ catch (error) {
275
+ if (own === generation)
276
+ readError = error;
277
+ }
278
+ if (own === generation)
279
+ finishTurn();
280
+ })();
281
+ }
282
+ connect();
213
283
  async function runTurn(line) {
214
284
  input.setSuggestion(null);
215
285
  status?.turnStarted();
@@ -225,22 +295,59 @@ export async function runChatInk(options, tuiOptions = {}) {
225
295
  if (readError)
226
296
  throw readError;
227
297
  }
298
+ /** Draws the run's earlier conversation (when it has one) and says it was resumed. */
299
+ async function showConversation(folder, fromResume) {
300
+ if (!folder.sessionId)
301
+ return;
302
+ const messages = await readConversation(folder.dir);
303
+ const [summary] = (await listRuns(path.dirname(folder.dir))).filter((r) => path.resolve(r.dir) === path.resolve(folder.dir));
304
+ // In full screen the history is redrawn from the start; inline, the terminal's
305
+ // scrollback can't be taken back, so the conversation follows what's already there.
306
+ if (fromResume && tuiOptions.fullscreen)
307
+ model.reset();
308
+ model.replay(messages, userLine);
309
+ model.writeLine(ui.dim(t().resumed(runDate(summary?.updatedAt ?? new Date()))));
310
+ }
311
+ /** /resume: picks one of the runs and switches the session to it. */
312
+ async function resume() {
313
+ if (!opener || !runsDir)
314
+ return;
315
+ const runs = await listRuns(runsDir);
316
+ if (runs.length === 0) {
317
+ model.writeLine(ui.dim(t().noEarlierRuns));
318
+ return;
319
+ }
320
+ const chosen = await input.pick(t().resumeTitle, runs.map((summary, i) => ({ label: `${i + 1}. ${runLabel(summary, current.run ?? undefined)}`, value: summary.dir })));
321
+ const summary = runs.find((r) => r.dir === chosen);
322
+ if (!summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
323
+ return;
324
+ queue.end();
325
+ run.close();
326
+ current = await openSession(opener, await runFolderOf(summary));
327
+ openLog();
328
+ connect();
329
+ await showConversation(current.run, true);
330
+ }
228
331
  const previousPort = getInteractionPort();
229
332
  setInteractionPort(interaction.port);
230
333
  const restoreTerminal = tuiOptions.fullscreen ? enterFullscreen(stdout) : null;
231
334
  const cursor = tuiOptions.fullscreen ? createCursorController(stdout) : null;
232
335
  const status = tuiOptions.terminalIntegration === false ? null : createTerminalStatus((text) => void stdout.write(text), tuiOptions.header?.title ?? "agent");
233
336
  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 }) }),
337
+ const app = render(_jsx(CursorContext.Provider, { value: cursor, children: _jsx(KitTheme, { 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
338
  // In full screen Ink writes through the cursor controller, which places the terminal's cursor.
236
339
  { exitOnCtrlC: false, ...(cursor ? { stdout: cursor.stream } : {}) });
237
340
  try {
238
341
  // In full screen the header is pinned above the history (see SessionView) instead.
239
342
  if (tuiOptions.header && !tuiOptions.fullscreen)
240
343
  model.note(headerLines(tuiOptions.header).join("\n"));
344
+ for (const warning of languageWarnings)
345
+ model.writeLine(ui.warn(warning));
241
346
  if (tuiOptions.welcomeMessage)
242
347
  model.writeLine(tuiOptions.welcomeMessage);
243
- if (tuiOptions.initialPrompt) {
348
+ if (current.run)
349
+ await showConversation(current.run, false);
350
+ if (tuiOptions.initialPrompt && !current.run?.sessionId) {
244
351
  // Logged but not shown, like runChatTui(): it is the agent's cue, not something typed.
245
352
  mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
246
353
  await runTurn(tuiOptions.initialPrompt);
@@ -256,7 +363,7 @@ export async function runChatInk(options, tuiOptions = {}) {
256
363
  if (!line)
257
364
  continue;
258
365
  mirror(`${promptLabel}${line}\n`);
259
- model.note(`${promptLabel.replace(/^\n+/, "")}${line}`, "user");
366
+ model.note(userLine(line), "user");
260
367
  if (tuiOptions.historyPath) {
261
368
  historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
262
369
  await saveHistory(tuiOptions.historyPath, historyEntries);
@@ -268,14 +375,18 @@ export async function runChatInk(options, tuiOptions = {}) {
268
375
  const reply = model.lastReply();
269
376
  if (reply)
270
377
  stdout.write(clipboardSequence(reply));
271
- model.writeLine(ui.dim(reply ? `(copied the last reply: ${reply.length} characters)` : "(nothing to copy yet)"));
378
+ model.writeLine(ui.dim(reply ? t().copiedReply(reply.length) : t().nothingToCopy));
379
+ continue;
380
+ }
381
+ if (runsDir && line.toLowerCase() === "/resume") {
382
+ await resume();
272
383
  continue;
273
384
  }
274
385
  const commandToken = slashCommandToken(line);
275
386
  if (commandToken) {
276
387
  const names = await knownCommands;
277
388
  if (names && !names.has(commandToken)) {
278
- model.writeLine(ui.warn(`Unknown command: /${commandToken}`));
389
+ model.writeLine(ui.warn(t().unknownCommand(commandToken)));
279
390
  continue;
280
391
  }
281
392
  }
@@ -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
- import { fitWidth } from "./lineBuffer.js";
1
+ import { fitWidth, stripAnsi } 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,13 +33,15 @@ 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;
54
41
  // Rendered wide, as one row: the summary line is cut to the room left afterwards.
55
- return (markdown ? (renderMarkdown(first, 1000)[0] ?? first) : first) + more;
42
+ // Plain before the theme's color: a style the markdown opened and closed (bold's reset)
43
+ // would cancel a dim around it.
44
+ return ui.toolResult(markdown ? stripAnsi(renderMarkdown(first, 1000)[0] ?? first) : first) + more;
56
45
  }
57
46
  /**
58
47
  * The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
@@ -65,12 +54,12 @@ export function toolGroupExpanded(calls, width) {
65
54
  const shown = children.slice(-MAX_CHILDREN);
66
55
  const result = call.result ? fitWidth(resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName)), width - 5) : ui.dim("…");
67
56
  const under = [
68
- ...(children.length > shown.length ? [ui.dim(`… ${children.length - shown.length} earlier`)] : []),
57
+ ...(children.length > shown.length ? [ui.dim(t().earlierCalls(children.length - shown.length))] : []),
69
58
  ...shown.map((label) => fitWidth(ui.dim(`· ${label}`), width - 5)),
70
59
  result,
71
60
  ];
72
61
  return [
73
- `${ui.toolBullet("●")} ${fitWidth(call.label, width - 2)}`,
62
+ `${ui.toolBullet("●")} ${fitWidth(ui.toolLabel(call.label), width - 2)}`,
74
63
  ...under.map((line, i) => (line === "" ? "" : ` ${i === 0 ? ui.dim("⎿") : " "} ${line}`)),
75
64
  ];
76
65
  });