@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
@@ -5,9 +5,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { createInputQueue } from "../core/session.js";
7
7
  import { runQuery } from "../core/runner.js";
8
+ import { listRuns, readConversation } from "../core/runs.js";
8
9
  import { isSharedQuestionActive, setSharedReadline } from "./terminalInteraction.js";
9
10
  import * as ui from "./ui.js";
11
+ import { t } from "../core/messages/index.js";
12
+ import { applyLanguage } from "./language.js";
13
+ import { applyTheme } from "./theme.js";
10
14
  import { createConsoleRenderer } from "./consoleRenderer.js";
15
+ import { firstRun, openSession, runDate, runFolderOf, runLabel } from "./runs.js";
11
16
  // Only strips picocolors' own SGR sequences (`\x1b[<codes>m`) — the only kind this file
12
17
  // ever writes to the console — not a general-purpose ANSI stripper for arbitrary escape
13
18
  // sequences (cursor movement, OSC, ...) this kit never emits.
@@ -92,16 +97,28 @@ export async function drainTurn(events, onEvent) {
92
97
  * const { options } = await buildSessionOptions(config, runDir, spec);
93
98
  * await runChatTui(options, { welcomeMessage: "Ready. Type /exit to quit." });
94
99
  *
100
+ * or, to keep each run's conversation in its folder and resume it (see `runsDir`):
101
+ *
102
+ * await runChatTui((run) => buildSessionOptions(config, run.dir, spec, { run }), { runsDir });
103
+ *
95
104
  * Resolves once the chat ends (an exit command, Ctrl+C/Ctrl+D at an idle prompt) — this
96
105
  * function owns the whole session lifecycle, closing the underlying query and readline
97
106
  * interface itself before returning.
98
107
  */
99
108
  export async function runChatTui(options, tuiOptions = {}) {
109
+ applyLanguage(tuiOptions.language);
110
+ applyTheme(tuiOptions.theme);
100
111
  const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
101
112
  const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
102
- const queue = createInputQueue();
103
- const run = runQuery(queue.iterable, options);
104
- const events = run.events[Symbol.asyncIterator]();
113
+ const opener = typeof options === "function" ? options : null;
114
+ const runsDir = opener ? tuiOptions.runsDir : undefined;
115
+ if (opener && !runsDir)
116
+ throw new Error("runChatTui(): a session opener needs `runsDir`.");
117
+ // The session in use: with runs, the run's folder, rebuilt on /resume.
118
+ let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
119
+ let queue = createInputQueue();
120
+ let run = runQuery(queue.iterable, current.options);
121
+ let events = run.events[Symbol.asyncIterator]();
105
122
  const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
106
123
  let historyEntries = tuiOptions.historyPath ? capHistory(await loadHistory(tuiOptions.historyPath), historyLimit) : [];
107
124
  const rl = readline.createInterface({
@@ -113,7 +130,14 @@ export async function runChatTui(options, tuiOptions = {}) {
113
130
  historySize: historyLimit,
114
131
  });
115
132
  setSharedReadline(rl);
116
- const sessionLog = tuiOptions.sessionLogPath ? createWriteStream(tuiOptions.sessionLogPath, { flags: "a" }) : null;
133
+ // With runs, the session log is the run's own; it moves with /resume.
134
+ let sessionLog = null;
135
+ function openLog() {
136
+ sessionLog?.end();
137
+ const file = current.run ? path.join(current.run.dir, "session.log") : tuiOptions.sessionLogPath;
138
+ sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
139
+ }
140
+ openLog();
117
141
  // Mirrors to the log file only — for text already visible on the terminal some other
118
142
  // way (readline's own prompt + the human's local echo), so it isn't printed twice.
119
143
  function mirror(text) {
@@ -182,7 +206,7 @@ export async function runChatTui(options, tuiOptions = {}) {
182
206
  return;
183
207
  turnInterrupted = true;
184
208
  void run.interrupt();
185
- writeLine(ui.warn("(interrupted)"));
209
+ writeLine(ui.warn(t().interrupted));
186
210
  }
187
211
  rl.on("SIGINT", () => {
188
212
  if (turnInFlight) {
@@ -203,10 +227,48 @@ export async function runChatTui(options, tuiOptions = {}) {
203
227
  interruptTurn();
204
228
  };
205
229
  stdin.on("keypress", onKeypress);
230
+ /** Prints the run's earlier conversation (when it has one) and says it was resumed. */
231
+ async function showConversation(folder) {
232
+ if (!folder.sessionId)
233
+ return;
234
+ for (const message of await readConversation(folder.dir)) {
235
+ // Printed, not logged: the run's session log already has it.
236
+ stdout.write(message.role === "user" ? `${promptLabel}${message.text}\n` : `\n${tuiOptions.agentLabel ? `${tuiOptions.agentLabel} ` : ""}${message.text}\n`);
237
+ }
238
+ const [summary] = (await listRuns(path.dirname(folder.dir))).filter((r) => path.resolve(r.dir) === path.resolve(folder.dir));
239
+ writeLine(ui.dim(`\n${t().resumed(runDate(summary?.updatedAt ?? new Date()))}`));
240
+ }
241
+ /** /resume: lists the runs, asks for one and switches the session to it. */
242
+ async function resume() {
243
+ if (!opener || !runsDir)
244
+ return;
245
+ const runs = await listRuns(runsDir);
246
+ if (runs.length === 0) {
247
+ writeLine(ui.dim(t().noEarlierRuns));
248
+ return;
249
+ }
250
+ writeLine(ui.heading(t().resumeTitle));
251
+ runs.forEach((summary, i) => writeLine(` ${i + 1}. ${runLabel(summary, current.run ?? undefined)}`));
252
+ const answer = (await rl.question(t().resumeQuestion)).trim();
253
+ const summary = runs[Number(answer) - 1];
254
+ if (!answer || !summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
255
+ return;
256
+ queue.end();
257
+ run.close();
258
+ current = await openSession(opener, await runFolderOf(summary));
259
+ openLog();
260
+ queue = createInputQueue();
261
+ run = runQuery(queue.iterable, current.options);
262
+ events = run.events[Symbol.asyncIterator]();
263
+ knownCommandTokens = null;
264
+ await showConversation(current.run);
265
+ }
206
266
  if (tuiOptions.welcomeMessage)
207
267
  writeLine(tuiOptions.welcomeMessage);
208
268
  try {
209
- if (tuiOptions.initialPrompt) {
269
+ if (current.run)
270
+ await showConversation(current.run);
271
+ if (tuiOptions.initialPrompt && !current.run?.sessionId) {
210
272
  mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
211
273
  queue.push(tuiOptions.initialPrompt);
212
274
  turnInFlight = true;
@@ -233,6 +295,10 @@ export async function runChatTui(options, tuiOptions = {}) {
233
295
  }
234
296
  if (exitCommands.has(line.toLowerCase()))
235
297
  break;
298
+ if (runsDir && line.toLowerCase() === "/resume") {
299
+ await resume();
300
+ continue;
301
+ }
236
302
  // A line that *looks* like a slash command but matches nothing registered (a typo,
237
303
  // an unnamespaced plugin command, ...) would otherwise reach the model as literal
238
304
  // text with no special handling — confirmed empirically (captain-whiskers'
@@ -242,7 +308,7 @@ export async function runChatTui(options, tuiOptions = {}) {
242
308
  // silence on unrecognized input might mean.
243
309
  const commandToken = slashCommandToken(line);
244
310
  if (commandToken && !(await isKnownSlashCommand(commandToken))) {
245
- writeLine(ui.warn(`Unknown command: /${commandToken}`));
311
+ writeLine(ui.warn(t().unknownCommand(commandToken)));
246
312
  continue;
247
313
  }
248
314
  queue.push(line);
@@ -16,4 +16,6 @@ import { type ClaudeAuthConfig } from "../core/claudeAuth.js";
16
16
  * Only makes sense with a real terminal in front of a human (a `runWizard()` confirm, colored console
17
17
  * output), which is why it lives here rather than in the non-interactive core.
18
18
  */
19
- export declare function ensureClaudeAuth(config?: ClaudeAuthConfig): Promise<string | undefined>;
19
+ export declare function ensureClaudeAuth(config?: ClaudeAuthConfig, options?: {
20
+ language?: string;
21
+ }): Promise<string | undefined>;
@@ -3,6 +3,8 @@ import { resolveClaudeAuth } from "../core/claudeAuth.js";
3
3
  import { isExitPromptError } from "./promptErrors.js";
4
4
  import * as ui from "./ui.js";
5
5
  import { runWizard } from "./ink/wizard.js";
6
+ import { applyLanguage } from "./language.js";
7
+ import { t } from "../core/messages/index.js";
6
8
  // The real format of "claude setup-token" tokens ("sk-ant-oat01-...", seen in the
7
9
  // installed @anthropic-ai/claude-code binary) — used to extract the token from its output.
8
10
  const TOKEN_PATTERN = /sk-ant-oat\d{2}-[A-Za-z0-9_-]+/;
@@ -61,23 +63,17 @@ function runSetupToken() {
61
63
  * Only makes sense with a real terminal in front of a human (a `runWizard()` confirm, colored console
62
64
  * output), which is why it lives here rather than in the non-interactive core.
63
65
  */
64
- export async function ensureClaudeAuth(config = {}) {
66
+ export async function ensureClaudeAuth(config = {}, options = {}) {
65
67
  if (resolveClaudeAuth(config))
66
68
  return undefined;
67
- console.log(ui.warn("\nNo Claude authentication token was found"));
68
- console.log(ui.dim("(neither the CLAUDE_CODE_OAUTH_TOKEN environment variable, nor one passed in)."));
69
+ applyLanguage(options.language);
70
+ const texts = t().auth;
71
+ console.log(ui.warn(`\n${texts.noToken}`));
72
+ console.log(ui.dim(texts.noTokenDetail));
69
73
  try {
70
- const { generate } = await runWizard([
71
- {
72
- type: "confirm",
73
- name: "generate",
74
- message: 'Generate one now with "claude setup-token" (requires a Claude Pro/Max subscription)?',
75
- default: true,
76
- },
77
- ]);
74
+ const { generate } = await runWizard([{ type: "confirm", name: "generate", message: texts.generateQuestion, default: true }]);
78
75
  if (!generate) {
79
- console.log(ui.error("\nThe agent can't start without a token. Set CLAUDE_CODE_OAUTH_TOKEN by hand " +
80
- "(or run this again and accept generating it) and try again."));
76
+ console.log(ui.error(`\n${texts.cantStart}`));
81
77
  process.exit(0);
82
78
  }
83
79
  }
@@ -86,11 +82,10 @@ export async function ensureClaudeAuth(config = {}) {
86
82
  process.exit(0);
87
83
  throw error;
88
84
  }
89
- console.log(ui.heading("\n=== Generating a Claude authentication token ==="));
90
- console.log(ui.dim("A browser will open to log in; follow the instructions the command itself prints " +
91
- "below.\n"));
85
+ console.log(ui.heading(`\n${texts.generatingHeading}`));
86
+ console.log(ui.dim(`${texts.browserWillOpen}\n`));
92
87
  const token = await runSetupToken();
93
88
  process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
94
- console.log(ui.success("\nToken generated for this run. Save it yourself (e.g. CLAUDE_CODE_OAUTH_TOKEN in your shell profile) to skip this next time.\n"));
89
+ console.log(ui.success(`\n${texts.generated}\n`));
95
90
  return token;
96
91
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentEvent } from "../core/runner.js";
2
+ /** Options of `createConsoleRenderer()`. */
2
3
  export interface ConsoleRendererOptions {
3
4
  /** Turns a tool call into its console label; defaults to `createFriendlyToolLabel()`. */
4
5
  formatAction?: (toolName: string, toolInput: unknown) => string;
@@ -9,6 +10,7 @@ export interface ConsoleRendererOptions {
9
10
  /** Called after every write with the same text, e.g. to mirror it into a log file. */
10
11
  onWrite?: (text: string) => void;
11
12
  }
13
+ /** Prints `runQuery()`'s events as plain console lines (see `createConsoleRenderer()`). */
12
14
  export interface ConsoleRenderer {
13
15
  /** Prints one normalized event from `runQuery()`. */
14
16
  render(event: AgentEvent): void;
@@ -1,6 +1,7 @@
1
1
  import { stdout } from "node:process";
2
2
  import { createFriendlyToolLabel } from "../core/toolLabels.js";
3
3
  import * as ui from "./ui.js";
4
+ import { t } from "../core/messages/index.js";
4
5
  /**
5
6
  * The console rendering of `runQuery()`'s event stream, shared by `runChatTui()` and by any
6
7
  * one-shot run that prints the same events. Streamed text rarely ends in a newline, so
@@ -46,7 +47,7 @@ export function createConsoleRenderer(options = {}) {
46
47
  case "prompt-suggestion":
47
48
  return;
48
49
  case "mcp-error":
49
- writeLine(ui.warn(`Some MCP servers failed to connect: ${event.failedServers.join(", ")}`));
50
+ writeLine(ui.warn(t().mcpFailed(event.failedServers.join(", "))));
50
51
  return;
51
52
  case "info":
52
53
  writeLine((event.level === "warning" ? ui.warn : ui.dim)(`(${event.text})`));
@@ -27,8 +27,6 @@ export interface PromptInputProps {
27
27
  /** Project files for `@` mentions (paths relative to the session's folder). */
28
28
  files?: readonly string[];
29
29
  }
30
- /** The shortcuts panel, opened with "?" on an empty prompt. */
31
- export declare const SHORTCUTS: string[];
32
30
  /**
33
31
  * The chat's prompt: history (↑/↓), "/command" completion and suggestions (Tab), pasted
34
32
  * blocks folded into a token, multi-line input (`\` + Enter or Ctrl+J), word and line
@@ -8,8 +8,9 @@ import { isMouseReport } from "./fullscreen.js";
8
8
  import { CursorContext } from "./terminalCursor.js";
9
9
  import { isFocusReport } from "./terminalStatus.js";
10
10
  import { matchingFiles, mentionAt } from "./fileMentions.js";
11
- import { continueLine, createPasteRegistry, cursorLine, deleteBack, deleteForward, deleteWordBack, insert, isBlockPaste, isForwardDelete, killToLineEnd, moveLine, normalizePaste, searchHistory, toLineEnd, toLineStart, wordLeft, wordRight, } from "./promptEditing.js";
11
+ import { continueLine, createPasteRegistry, cursorLine, deleteBack, deleteForward, deleteWordBack, insert, isBlockPaste, isForwardDelete, killToLineEnd, moveLine, normalizePaste, PASTE_TOKEN_AFTER, PASTE_TOKEN_BEFORE, searchHistory, toLineEnd, toLineStart, wordLeft, wordRight, } from "./promptEditing.js";
12
12
  import * as ui from "../ui.js";
13
+ import { t } from "../../core/messages/index.js";
13
14
  const MAX_SUGGESTIONS = 6;
14
15
  /** Commands (names without "/") a partial "/name" line could still become. */
15
16
  export function matchingCommands(value, commands) {
@@ -44,19 +45,8 @@ export function visibleWindow(length, cursor, room) {
44
45
  const start = Math.min(Math.max(0, cursor + 1 - span), Math.max(0, length + 1 - span));
45
46
  return [start, Math.min(length, start + span)];
46
47
  }
47
- /** The shortcuts panel, opened with "?" on an empty prompt. */
48
- export const SHORTCUTS = [
49
- "Enter send · \\ + Enter or Ctrl+J new line · Tab complete or take the suggestion",
50
- "↑/↓ history · Ctrl+R search history · ↑ on an empty prompt edits the last queued message",
51
- "Ctrl+W delete word · Ctrl+K to line end · Ctrl+U clear · Ctrl+←/→ move by word",
52
- "@ mention a file · /copy copy the last reply · Shift+Tab switch mode · Ctrl+O unfold tool calls",
53
- "Esc interrupt · Ctrl+C interrupt or exit · PgUp/PgDn or wheel scroll · Ctrl+End bottom",
54
- "Drag to select, right-click to copy (full screen)",
55
- ];
56
48
  // A paste can reach us in several chunks; pieces this close together extend the same one.
57
49
  const PASTE_CHUNK_MS = 100;
58
- const PASTE_TOKEN_BEFORE = /\[Pasted text #\d+ \+\d+ lines?\]$/;
59
- const PASTE_TOKEN_AFTER = /^\[Pasted text #\d+ \+\d+ lines?\]/;
60
50
  /**
61
51
  * The chat's prompt: history (↑/↓), "/command" completion and suggestions (Tab), pasted
62
52
  * blocks folded into a token, multi-line input (`\` + Enter or Ctrl+J), word and line
@@ -310,7 +300,7 @@ export function PromptInput({ label, inset = 0, suggestion, history, commands, o
310
300
  const width = liveWidth(stdout.columns) - inset;
311
301
  const labelWidth = stringWidth(stripAnsi(label));
312
302
  const room = width - labelWidth;
313
- const ghost = suggestion ? `${suggestion} (tab)` : null;
303
+ const ghost = suggestion ? `${suggestion} (${t().suggestionKey})` : null;
314
304
  // Suggestions give way on a short terminal: the live area must stay shorter than it.
315
305
  const maxSuggestions = Math.min(MAX_SUGGESTIONS, Math.max(0, (stdout.rows || 24) - 10));
316
306
  const mention = mentionAt(value, cursor);
@@ -326,7 +316,7 @@ export function PromptInput({ label, inset = 0, suggestion, history, commands, o
326
316
  let rows;
327
317
  let cursorColumn;
328
318
  if (search) {
329
- const prefix = `(reverse-i-search)'${search.query}': `;
319
+ const prefix = t().reverseSearch(search.query);
330
320
  const match = search.index >= 0 ? history[search.index] : "";
331
321
  cursorColumn = Math.min(stringWidth(prefix) - 3, width - 1);
332
322
  rows = [
@@ -352,5 +342,5 @@ export function PromptInput({ label, inset = 0, suggestion, history, commands, o
352
342
  cursorController.setTarget({ node: cursorLineRef.current, column: cursorColumn });
353
343
  });
354
344
  useLayoutEffect(() => () => cursorController?.setTarget(null), [cursorController]);
355
- return (_jsxs(Box, { flexDirection: "column", children: [rows, suggestions.map((item) => (_jsx(Text, { children: ui.dim(fitWidth(` ${item}`, width)) }, item))), showHelp ? SHORTCUTS.map((line) => _jsx(Text, { children: ui.dim(fitWidth(` ${line}`, width)) }, line)) : null] }));
345
+ return (_jsxs(Box, { flexDirection: "column", children: [rows, suggestions.map((item) => (_jsx(Text, { children: ui.dim(fitWidth(` ${item}`, width)) }, item))), showHelp ? t().shortcuts.map((line) => _jsx(Text, { children: ui.dim(fitWidth(` ${line}`, width)) }, line)) : null] }));
356
346
  }
@@ -8,15 +8,16 @@ import { framePosition } from "./terminalCursor.js";
8
8
  import { isFocusReport } from "./terminalStatus.js";
9
9
  import { fitWidth, stripAnsi } from "./lineBuffer.js";
10
10
  import * as ui from "../ui.js";
11
+ import { t } from "../../core/messages/index.js";
12
+ import { inkColor } from "../theme.js";
11
13
  // Numbered as in the Claude Code CLI's permission prompt; the digits and y/n/q answer too.
12
- const DECISION_OPTIONS = [
13
- { label: "1. Yes", value: "y" },
14
- { label: "2. No", value: "n" },
15
- { label: "3. Stop", value: "q" },
14
+ const decisionOptions = () => [
15
+ { label: `1. ${t().yes}`, value: "y" },
16
+ { label: `2. ${t().no}`, value: "n" },
17
+ { label: `3. ${t().stop}`, value: "q" },
16
18
  ];
17
- const MANUAL_OPTIONS = [{ label: "1. Done, continue", value: "continue" }];
19
+ const manualOptions = () => [{ label: `1. ${t().doneContinue}`, value: "continue" }];
18
20
  const DECISION_KEYS = { "1": "y", "2": "n", "3": "q", y: "y", n: "n", q: "q" };
19
- const ACCENT_HEX = "#d77757";
20
21
  /**
21
22
  * The preview's lines, capped so the whole live area stays shorter than the terminal: Ink
22
23
  * clears the entire screen (scrollback included) to redraw anything taller, and a step-gate
@@ -25,7 +26,7 @@ const ACCENT_HEX = "#d77757";
25
26
  export function previewLines(lines, terminalRows) {
26
27
  const all = lines.flatMap((line) => line.split("\n"));
27
28
  const max = Math.max(3, (terminalRows || 24) - 12);
28
- return all.length <= max ? all : [...all.slice(0, max - 1), ui.dim(`… (${all.length - max + 1} more lines)`)];
29
+ return all.length <= max ? all : [...all.slice(0, max - 1), ui.dim(t().moreLines(all.length - max + 1))];
29
30
  }
30
31
  function CheckpointPanel({ checkpoint, renderApproval }) {
31
32
  const decision = checkpoint.kind === "decision";
@@ -38,17 +39,17 @@ function CheckpointPanel({ checkpoint, renderApproval }) {
38
39
  });
39
40
  const { prompt } = checkpoint;
40
41
  const { stdout } = useStdout();
41
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: ACCENT_HEX, paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Do you want to proceed?" }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? DECISION_OPTIONS : MANUAL_OPTIONS, onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
42
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
42
43
  }
43
44
  function formatTokens(count) {
44
45
  return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
45
46
  }
46
47
  export function statusText(mode, turns, usage, extra = {}) {
47
48
  const parts = [
48
- ...(mode ? [`⏵⏵ ${mode}${extra.modeSwitchable ? " (shift+tab)" : ""}`] : []),
49
- `${turns} ${turns === 1 ? "turn" : "turns"}`,
50
- ...(usage ? [`${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out`] : []),
51
- ...(extra.contextPercent != null ? [`context ${Math.round(extra.contextPercent)}%`] : []),
49
+ ...(mode ? [`⏵⏵ ${t().mode(mode)}${extra.modeSwitchable ? ` (${t().switchModeKey})` : ""}`] : []),
50
+ t().turns(turns),
51
+ ...(usage ? [t().tokens(formatTokens(usage.inputTokens), formatTokens(usage.outputTokens))] : []),
52
+ ...(extra.contextPercent != null ? [t().context(Math.round(extra.contextPercent))] : []),
52
53
  ];
53
54
  return parts.join(" · ");
54
55
  }
@@ -63,7 +64,7 @@ function Working({ label, startedAt, width }) {
63
64
  }, []);
64
65
  const seconds = startedAt === null ? 0 : Math.floor((Date.now() - startedAt) / 1000);
65
66
  const glyph = WORKING_GLYPHS[tick % WORKING_GLYPHS.length];
66
- return _jsx(Text, { children: fitWidth(`${ui.working(`${glyph} ${label}`)} ${ui.dim(`(${seconds}s · esc to interrupt)`)}`, width) });
67
+ return _jsx(Text, { children: fitWidth(`${ui.working(`${glyph} ${label}`)} ${ui.dim(`(${seconds}s · ${t().escToInterrupt})`)}`, width) });
67
68
  }
68
69
  /** Line in progress, checkpoint panel or spinner, the input, and the status bar. */
69
70
  function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtra, modeSwitchable, children }) {
@@ -74,7 +75,7 @@ function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtr
74
75
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, marginTop: session.liveGap ? 1 : 0, children: [stripAnsi(session.live) ? _jsx(Text, { children: session.live }) : null, checkpoint ? (_jsx(CheckpointPanel, { checkpoint: checkpoint, renderApproval: renderApproval })) : session.busy ? (
75
76
  // Always one blank line above the spinner: from this margin, or from the live area's
76
77
  // own when a separator is pending and nothing is written above the spinner yet.
77
- _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? "Thinking…", startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
78
+ _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? t().thinking, startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
78
79
  }
79
80
  /** A history line for the inline view: the human's line as a bar, a tool group unfolded with Ctrl+O. */
80
81
  function inlineItem(item, expanded, width) {
@@ -184,7 +185,7 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
184
185
  setSelection(null);
185
186
  if (text && onCopy) {
186
187
  onCopy(text);
187
- setCopied(`copied ${text.length} ${text.length === 1 ? "character" : "characters"}`);
188
+ setCopied(t().copiedCharacters(text.length));
188
189
  }
189
190
  }
190
191
  }
@@ -205,7 +206,7 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
205
206
  setAnchor(null); // typing
206
207
  });
207
208
  const below = rowsBelow(anchor, total);
208
- const statusExtra = [anchor === null ? null : `↓ ${below} more ${below === 1 ? "line" : "lines"} (Ctrl+End)`, copied]
209
+ const statusExtra = [anchor === null ? null : t().linesBelow(below), copied]
209
210
  .filter(Boolean)
210
211
  .join(" · ");
211
212
  return (_jsxs(Box, { flexDirection: "column", width: columns - 1, height: rows, children: [header && header.length > 0 ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, children: header.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line || " " }, i))) })) : null, _jsx(Box, { ref: historyRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", justifyContent: "flex-end", children: wrapped.rows.slice(start, end).map((row, i) => (_jsx(Text, { wrap: "truncate-end", children: highlightRow(row, selection, start + i) || " " }, start + i))) }), _jsx(LiveArea, { session: session, checkpoint: checkpoint, renderApproval: renderApproval, mode: mode, width: width, statusExtra: statusExtra || undefined, modeSwitchable: modeSwitchable, children: children })] }));
@@ -1,3 +1,4 @@
1
+ /** The top of the Ink chat: a title, optional fields and an optional logo. */
1
2
  export interface HeaderInfo {
2
3
  title: string;
3
4
  /** Shown under the title as "name value" pairs, e.g. the workspace or the model. */
@@ -1,9 +1,6 @@
1
1
  import * as ui from "../ui.js";
2
- const OUTCOMES = {
3
- y: ui.success("✔ Approved"),
4
- n: ui.warn("✘ Rejected"),
5
- q: ui.error("■ Stopped"),
6
- };
2
+ import { t } from "../../core/messages/index.js";
3
+ const outcome = (answer) => ({ y: ui.success(t().approved), n: ui.warn(t().rejected), q: ui.error(t().stopped) })[answer];
7
4
  /**
8
5
  * An `InteractionPort` for the Ink views: each checkpoint waits in a queue (the step gate
9
6
  * can fire for several tool calls at once) until the panel answers it, or until the
@@ -37,13 +34,13 @@ export function createInkInteraction(note) {
37
34
  answer(value) {
38
35
  if (!remove(checkpoint))
39
36
  return;
40
- record(prompt, kind === "decision" ? (OUTCOMES[value] ?? value) : ui.success("✔ Done"));
37
+ record(prompt, kind === "decision" ? (outcome(value) ?? value) : ui.success(t().done));
41
38
  resolve(value);
42
39
  },
43
40
  };
44
41
  signal.addEventListener("abort", () => {
45
42
  if (remove(checkpoint))
46
- record(prompt, ui.dim("(answered through the response file)"));
43
+ record(prompt, ui.dim(t().answeredByFile));
47
44
  }, { once: true });
48
45
  queue = [...queue, checkpoint];
49
46
  emit();
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * `@inkjs/ui`'s components in the kit's theme: the focused option of a `Select` (and its
4
+ * `❯`) in the theme's `selection` color and bold, instead of the library's blue, which on
5
+ * most dark terminals is a navy that barely shows.
6
+ */
7
+ export declare function KitTheme({ children }: {
8
+ children: ReactNode;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { defaultTheme, extendTheme, ThemeProvider } from "@inkjs/ui";
3
+ import { inkColor } from "../theme.js";
4
+ /**
5
+ * `@inkjs/ui`'s components in the kit's theme: the focused option of a `Select` (and its
6
+ * `❯`) in the theme's `selection` color and bold, instead of the library's blue, which on
7
+ * most dark terminals is a navy that barely shows.
8
+ */
9
+ export function KitTheme({ children }) {
10
+ const selection = inkColor("selection");
11
+ const theme = extendTheme(defaultTheme, {
12
+ components: {
13
+ Select: {
14
+ styles: {
15
+ focusIndicator: () => ({ color: selection }),
16
+ label: ({ isFocused, isSelected }) => ({
17
+ color: isFocused ? selection : isSelected ? "green" : undefined,
18
+ bold: isFocused,
19
+ }),
20
+ },
21
+ },
22
+ },
23
+ });
24
+ return _jsx(ThemeProvider, { theme: theme, children: children });
25
+ }
@@ -79,16 +79,57 @@ function renderList(list, width) {
79
79
  });
80
80
  return lines;
81
81
  }
82
+ const visibleWidth = (text) => stringWidth(stripAnsi(text));
83
+ /**
84
+ * The widest columns shrunk, one column at a time, until the grid (`widths` plus one border
85
+ * and two spaces of padding per column, plus the last border) fits `room`; a column never
86
+ * goes below 3.
87
+ */
88
+ function fitColumns(widths, room) {
89
+ const fitted = [...widths];
90
+ const total = () => fitted.reduce((sum, w) => sum + w, 0) + fitted.length * 3 + 1;
91
+ while (total() > room) {
92
+ const widest = fitted.indexOf(Math.max(...fitted));
93
+ if (fitted[widest] <= 3)
94
+ break;
95
+ fitted[widest]--;
96
+ }
97
+ return fitted;
98
+ }
99
+ /**
100
+ * A table as a grid: dimmed box-drawing borders, one space of padding, the header in bold,
101
+ * a rule between rows and each column aligned as its markdown says. A table wider than the
102
+ * room shrinks its widest columns and wraps their cells onto more lines.
103
+ */
82
104
  function renderTable(table, width) {
83
105
  const cell = (c) => inline(c.tokens, c.text);
84
- const header = table.header.map(cell);
85
- const rows = table.rows.map((row) => row.map(cell));
86
- const widths = header.map((h, i) => Math.max(stringWidth(stripAnsi(h)), ...rows.map((row) => stringWidth(stripAnsi(row[i] ?? "")))));
87
- const pad = (text, i) => text + " ".repeat(Math.max(0, widths[i] - stringWidth(stripAnsi(text))));
88
- const line = (cells) => cells.map(pad).join(" ").trimEnd();
89
- const out = [ui.bold(line(header)), ui.dim(widths.map((w) => "─".repeat(w)).join(" ")), ...rows.map(line)];
90
- // A table wider than the room is cut, not wrapped: wrapping would scramble the columns.
91
- return out.map((row) => (stringWidth(stripAnsi(row)) > width ? wrapAnsi(row, width, { hard: true, wordWrap: false }).split("\n")[0] : row));
106
+ const header = table.header.map((c) => ui.bold(cell(c)));
107
+ const rows = table.rows.map((row) => table.header.map((_, i) => (row[i] ? cell(row[i]) : "")));
108
+ const natural = header.map((h, i) => Math.max(1, visibleWidth(h), ...rows.map((row) => visibleWidth(row[i]))));
109
+ const widths = fitColumns(natural, width);
110
+ const place = (text, i) => {
111
+ const room = widths[i] - visibleWidth(text);
112
+ if (room <= 0)
113
+ return text;
114
+ if (table.align[i] === "right")
115
+ return " ".repeat(room) + text;
116
+ if (table.align[i] === "center")
117
+ return " ".repeat(Math.floor(room / 2)) + text + " ".repeat(Math.ceil(room / 2));
118
+ return text + " ".repeat(room);
119
+ };
120
+ const border = (left, middle, right) => ui.dim(left + widths.map((w) => "─".repeat(w + 2)).join(middle) + right);
121
+ const bar = ui.dim("│");
122
+ // A row's cells wrapped to their columns: as many lines as its tallest cell.
123
+ const rowLines = (cells) => {
124
+ const wrapped = cells.map((text, i) => wrapAnsi(text, widths[i], { hard: true, trim: true }).split("\n"));
125
+ const height = Math.max(...wrapped.map((lines) => lines.length));
126
+ return Array.from({ length: height }, (_, line) => `${bar}${wrapped.map((lines, i) => ` ${place(lines[line] ?? "", i)} `).join(bar)}${bar}`);
127
+ };
128
+ const lines = [border("┌", "┬", "┐"), ...rowLines(header)];
129
+ for (const row of rows)
130
+ lines.push(border("├", "┼", "┤"), ...rowLines(row));
131
+ lines.push(border("└", "┴", "┘"));
132
+ return lines;
92
133
  }
93
134
  /** Inline tokens (bold, italic, code, links...) as styled text. */
94
135
  function inline(tokens, fallback) {
@@ -2,6 +2,8 @@ import type { Mode } from "../../core/agentSpec.js";
2
2
  import { type ConsoleRenderer, type ConsoleRendererOptions } from "../consoleRenderer.js";
3
3
  import { type RenderApproval } from "./SessionView.js";
4
4
  import type { ToolPhrase } from "./toolGroup.js";
5
+ import { type Theme } from "../theme.js";
6
+ /** Options of `createProgressView()`. */
5
7
  export interface ProgressViewOptions extends Omit<ConsoleRendererOptions, "output"> {
6
8
  /** How one of the agent's own tools counts in a folded group's summary (see `InkChatOptions.toolPhrase`). */
7
9
  toolPhrase?: (toolName: string) => ToolPhrase | undefined;
@@ -11,7 +13,20 @@ export interface ProgressViewOptions extends Omit<ConsoleRendererOptions, "outpu
11
13
  mode?: Mode;
12
14
  /** Use the plain console renderer even on a TTY. */
13
15
  plain?: boolean;
16
+ /**
17
+ * Colors for the kit's roles (see `Theme`), on top of the kit's defaults: only the roles
18
+ * given change, e.g. `{ toolResult: "yellow", selection: "#00ff00" }`. One theme per
19
+ * process: without this option, the one already set stays.
20
+ */
21
+ theme?: Partial<Theme>;
22
+ /**
23
+ * The language of the kit's texts ("en", "es", "fr", "de"). `--language=<code>` on the
24
+ * command line wins; without either, the one `buildSessionOptions()` chose from
25
+ * `config.language`, or else the system's.
26
+ */
27
+ language?: string;
14
28
  }
29
+ /** A one-shot run's live view: a console renderer drawn with Ink, plus `close()`. */
15
30
  export interface ProgressView extends ConsoleRenderer {
16
31
  /** Removes the live area and restores the previous interaction port; await it before printing anything else. */
17
32
  close(): Promise<void>;
@@ -7,6 +7,9 @@ import { createConsoleRenderer } from "../consoleRenderer.js";
7
7
  import { createInkInteraction } from "./inkInteraction.js";
8
8
  import { createSessionModel, liveWidth } from "./sessionModel.js";
9
9
  import { SessionView } from "./SessionView.js";
10
+ import { applyLanguage } from "../language.js";
11
+ import { applyTheme } from "../theme.js";
12
+ import { KitTheme } from "./inkTheme.js";
10
13
  function ProgressApp({ model, interaction, renderApproval, mode, closed }) {
11
14
  const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
12
15
  // Only a panel reads the keyboard (and so puts stdin in raw mode, where Ctrl+C no longer
@@ -29,6 +32,8 @@ function ProgressApp({ model, interaction, renderApproval, mode, closed }) {
29
32
  * Without a TTY or with `plain`, it is `createConsoleRenderer()` with a no-op `close()`.
30
33
  */
31
34
  export function createProgressView(options = {}) {
35
+ applyLanguage(options.language);
36
+ applyTheme(options.theme);
32
37
  if (options.plain || !stdin.isTTY || !stdout.isTTY) {
33
38
  const renderer = createConsoleRenderer(options);
34
39
  return Object.assign(renderer, { close: async () => { } });
@@ -37,7 +42,7 @@ export function createProgressView(options = {}) {
37
42
  const interaction = createInkInteraction((text) => model.note(text));
38
43
  const previousPort = getInteractionPort();
39
44
  setInteractionPort(interaction.port);
40
- const app = (closed) => (_jsx(ProgressApp, { model: model, interaction: interaction, renderApproval: options.renderApproval, mode: options.mode, closed: closed }));
45
+ const app = (closed) => (_jsx(KitTheme, { children: _jsx(ProgressApp, { model: model, interaction: interaction, renderApproval: options.renderApproval, mode: options.mode, closed: closed }) }));
41
46
  const instance = render(app(false), { exitOnCtrlC: false });
42
47
  model.startTurn();
43
48
  let closing = null;
@@ -1,8 +1,3 @@
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
1
  export interface EditState {
7
2
  value: string;
8
3
  cursor: number;
@@ -51,6 +46,8 @@ export interface PasteRegistry {
51
46
  /** `value` with every token replaced by its text. */
52
47
  expand(value: string): string;
53
48
  }
49
+ export declare const PASTE_TOKEN_BEFORE: RegExp;
50
+ export declare const PASTE_TOKEN_AFTER: RegExp;
54
51
  /** Normalizes a pasted chunk's line breaks (terminals send "\r" or "\r\n"). */
55
52
  export declare function normalizePaste(text: string): string;
56
53
  /** A paste worth folding into a token: more than one line. */