@falkenslab/agent-kit 0.12.0 → 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 (39) hide show
  1. package/README.md +16 -2
  2. package/dist/core/claudeAuth.d.ts +1 -0
  3. package/dist/core/hooks/transcriptLogger.d.ts +1 -0
  4. package/dist/core/interaction.d.ts +3 -0
  5. package/dist/core/interaction.js +2 -0
  6. package/dist/core/language.d.ts +2 -0
  7. package/dist/core/language.js +1 -0
  8. package/dist/core/runner.d.ts +2 -0
  9. package/dist/core/runs.d.ts +2 -0
  10. package/dist/core/session.d.ts +1 -0
  11. package/dist/core/session.js +1 -0
  12. package/dist/core/toolLabels.d.ts +6 -0
  13. package/dist/core/toolLabels.js +5 -3
  14. package/dist/core/tools/humanApproval.d.ts +1 -0
  15. package/dist/core/tools/manualLogin.d.ts +1 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/tui/chatTui.d.ts +8 -0
  19. package/dist/tui/chatTui.js +2 -0
  20. package/dist/tui/consoleRenderer.d.ts +2 -0
  21. package/dist/tui/ink/SessionView.js +2 -2
  22. package/dist/tui/ink/header.d.ts +1 -0
  23. package/dist/tui/ink/inkTheme.d.ts +9 -0
  24. package/dist/tui/ink/inkTheme.js +25 -0
  25. package/dist/tui/ink/markdown.js +49 -8
  26. package/dist/tui/ink/progressView.d.ts +9 -0
  27. package/dist/tui/ink/progressView.js +4 -1
  28. package/dist/tui/ink/runChatInk.d.ts +1 -0
  29. package/dist/tui/ink/runChatInk.js +6 -3
  30. package/dist/tui/ink/toolGroup.js +5 -3
  31. package/dist/tui/ink/wizard.d.ts +14 -0
  32. package/dist/tui/ink/wizard.js +4 -1
  33. package/dist/tui/terminalInteraction.d.ts +2 -0
  34. package/dist/tui/terminalInteraction.js +2 -0
  35. package/dist/tui/theme.d.ts +60 -0
  36. package/dist/tui/theme.js +65 -0
  37. package/dist/tui/ui.d.ts +23 -3
  38. package/dist/tui/ui.js +42 -18
  39. package/package.json +79 -75
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # agent-kit
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@falkenslab/agent-kit?logo=npm)](https://www.npmjs.com/package/@falkenslab/agent-kit)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@falkenslab/agent-kit)](https://www.npmjs.com/package/@falkenslab/agent-kit)
5
+ [![types](https://img.shields.io/npm/types/@falkenslab/agent-kit)](https://www.npmjs.com/package/@falkenslab/agent-kit)
6
+ [![node](https://img.shields.io/node/v/@falkenslab/agent-kit)](https://nodejs.org/)
7
+ [![license](https://img.shields.io/npm/l/@falkenslab/agent-kit)](LICENSE)
8
+ [![docs](https://img.shields.io/badge/docs-falkenslab.github.io%2Fagent--kit-d77757)](https://falkenslab.github.io/agent-kit/)
9
+
3
10
  A foundation for building AI agents on top of the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It isn't an agent itself: it solves once what every agent needs (human oversight, safety, memory and a good terminal interface), so each concrete agent only has to write what belongs to its own domain: what it knows how to do, with which tools, and how it talks.
4
11
 
5
12
  ## What an agent built with it can do
@@ -12,6 +19,7 @@ A foundation for building AI agents on top of the [Claude Agent SDK](https://www
12
19
  - **Stay within limits.** It only reads and writes in the folders that belong to it, and never in protected files, such as one holding passwords.
13
20
  - **Use external tools.** MCP servers of its own or of the project, and skills and commands organized in plugins.
14
21
  - **Leave a trail.** It records every action and the whole conversation, with secrets hidden.
22
+ - **Look the way you want.** The terminal's colors come from a theme by roles (the agent's replies, tool results, the focused option, borders…): pass only the ones you want to change, as a color name, a hex or a function.
15
23
  - **Chat in a terminal the way Claude Code does.** Full screen, with the reply written live and its markdown already formatted, the tools it uses summed up in one line (Ctrl+O unfolds it), panels for approvals, history and search, a suggestion for the next message, pasted blocks, file mentions with `@`, mouse selection (right-click to copy), and the agent's status in the tab and in the Windows taskbar.
16
24
  - **Also work without a terminal.** The same foundation runs inside a desktop app or a server, which bring their own interface.
17
25
 
@@ -66,14 +74,17 @@ await runChatInk((run) => buildSessionOptions(config, run.dir, spec, { run }), {
66
74
  header: { title: "My agent" },
67
75
  mode: config.mode, // Shift+Tab switches between guided and step by step
68
76
  fullscreen: true,
77
+ theme: { toolResult: "gray", selection: "#ffb86c" }, // only the colors you want to change
69
78
  });
70
79
  ```
71
80
 
72
81
  Save it as `agent.ts` in a project with the kit and `tsx` installed and `"type": "module"` in its `package.json`, and run it with `npx tsx agent.ts`. Captain Whiskers is this same skeleton with more pieces: skills, subagents and its logo.
73
82
 
74
- ## More details
83
+ ## Documentation
84
+
85
+ The full documentation is at **[falkenslab.github.io/agent-kit](https://falkenslab.github.io/agent-kit/)**: a tutorial, guides for every part of the kit (modes, human in the loop, security, tools, subagents, the knowledge base, the terminal UI, runs, languages, themes, hosts without a terminal), recipes, and an API reference generated from the source, for each released version.
75
86
 
76
- The specification in [`.minispec/`](./.minispec/README.md) explains how each part is built and why, including the SDK behaviors checked by hand that motivate several of its safeguards. Captain Whiskers' code is the quickest way to see how an agent is put together.
87
+ For whoever works on the kit itself, the specification in [`.minispec/`](./.minispec/README.md) explains how each part is built and why, including the SDK behaviors checked by hand that motivate several of its safeguards.
77
88
 
78
89
  ## Development
79
90
 
@@ -83,6 +94,9 @@ The specification in [`.minispec/`](./.minispec/README.md) explains how each par
83
94
  | `npm run typecheck` | Checks the types without building |
84
95
  | `npm run lint` | Checks the code style |
85
96
  | `npm test` | Runs all the tests |
97
+ | `npm run docs:install` | Installs the documentation site's dependencies (once) |
98
+ | `npm run docs:start` | Serves the documentation site locally, reloading on changes |
99
+ | `npm run docs:build` | Builds the documentation site (fails on broken links) |
86
100
 
87
101
  To run a single test file:
88
102
 
@@ -1,3 +1,4 @@
1
+ /** What `resolveClaudeAuth()` and `ensureClaudeAuth()` accept besides the environment. */
1
2
  export interface ClaudeAuthConfig {
2
3
  /** A previously-resolved token the caller already has (from wherever it stores one, if
3
4
  * anywhere) — this module owns no persistence of its own, unlike an earlier version
@@ -1,4 +1,5 @@
1
1
  import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /** The two hooks that write `transcript.jsonl`: register `preToolUse` as a `PreToolUse` hook and `postToolUse` as a `PostToolUse` one. */
2
3
  export interface TranscriptLogger {
3
4
  preToolUse: HookCallback;
4
5
  postToolUse: HookCallback;
@@ -1,3 +1,4 @@
1
+ /** What a checkpoint shows the person: a title, detail lines and, in a plain terminal, the question. */
1
2
  export interface ApprovalPrompt {
2
3
  title: string;
3
4
  lines: string[];
@@ -25,5 +26,7 @@ export interface InteractionPort {
25
26
  /** A one-way message for the person. */
26
27
  notify(message: string): void;
27
28
  }
29
+ /** Installs the port every checkpoint asks through (`null`: answer through the response file only). One per process. */
28
30
  export declare function setInteractionPort(port: InteractionPort | null): void;
31
+ /** The port checkpoints ask through right now, or `null`. */
29
32
  export declare function getInteractionPort(): InteractionPort | null;
@@ -1,7 +1,9 @@
1
1
  let current = null;
2
+ /** Installs the port every checkpoint asks through (`null`: answer through the response file only). One per process. */
2
3
  export function setInteractionPort(port) {
3
4
  current = port;
4
5
  }
6
+ /** The port checkpoints ask through right now, or `null`. */
5
7
  export function getInteractionPort() {
6
8
  return current;
7
9
  }
@@ -1,5 +1,6 @@
1
1
  /** The languages the kit's own texts come in. */
2
2
  export type Language = "en" | "es" | "fr" | "de";
3
+ /** The language codes the kit's texts come in. */
3
4
  export declare const SUPPORTED_LANGUAGES: readonly Language[];
4
5
  /** Each language's name in English, as the model is told it (the kit's text for the model stays in English). */
5
6
  export declare const LANGUAGE_NAMES: Record<Language, string>;
@@ -14,6 +15,7 @@ export interface LanguageSources {
14
15
  /** The environment, for `LC_ALL`, `LC_MESSAGES` and `LANG` (e.g. "fr_FR.UTF-8"). */
15
16
  env?: Readonly<Record<string, string | undefined>>;
16
17
  }
18
+ /** The result of `resolveLanguage()`: the language to use and why any asked-for code was skipped. */
17
19
  export interface ResolvedLanguage {
18
20
  language: Language;
19
21
  /** One line per code that was asked for and isn't supported (English text, for the caller to show). */
@@ -1,3 +1,4 @@
1
+ /** The language codes the kit's texts come in. */
1
2
  export const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de"];
2
3
  /** Each language's name in English, as the model is told it (the kit's text for the model stays in English). */
3
4
  export const LANGUAGE_NAMES = { en: "English", es: "Spanish", fr: "French", de: "German" };
@@ -109,12 +109,14 @@ export interface SessionUsage {
109
109
  export declare function visibleErrors(errors: readonly string[]): string[];
110
110
  /** A tool result's content as plain text: its text parts joined, anything else skipped. */
111
111
  export declare function toolResultText(content: unknown): string;
112
+ /** How full the session's context window is (see `AgentRun.contextUsage()`). */
112
113
  export interface ContextUsage {
113
114
  /** 0-100. */
114
115
  percentage: number;
115
116
  totalTokens: number;
116
117
  maxTokens: number;
117
118
  }
119
+ /** A running session, as `runQuery()` returns it: its events and the controls of the SDK's `Query`. */
118
120
  export interface AgentRun {
119
121
  /** Normalized events for this run — iterate with `for await`. */
120
122
  events: AsyncIterable<AgentEvent>;
@@ -10,6 +10,7 @@ export interface RunFolder {
10
10
  /** The SDK session kept in this folder (to resume it), or null for a new run. */
11
11
  sessionId: string | null;
12
12
  }
13
+ /** A run that kept a conversation, as `listRuns()` lists it for a person to pick (see `/resume`). */
13
14
  export interface RunSummary extends RunFolder {
14
15
  sessionId: string;
15
16
  /** When the run's conversation last changed. */
@@ -17,6 +18,7 @@ export interface RunSummary extends RunFolder {
17
18
  /** The human's last message, on one line (empty if none). */
18
19
  lastMessage: string;
19
20
  }
21
+ /** One message of a kept conversation: something the person wrote, or the agent's text reply. */
20
22
  export interface ConversationMessage {
21
23
  role: "user" | "assistant";
22
24
  text: string;
@@ -47,6 +47,7 @@ export interface ModeControl {
47
47
  /** Switches to `next` if this session allows it; returns whether it did. */
48
48
  set(next: Mode): boolean;
49
49
  }
50
+ /** A `ModeControl` starting at `initial` (`buildSessionOptions()` returns one for its session). */
50
51
  export declare function createModeControl(initial: Mode): ModeControl;
51
52
  /**
52
53
  * User message queue backed by a single long-lived generator, for any multi-turn
@@ -186,6 +186,7 @@ function skillList(skills, includeKnowledgeBase) {
186
186
  return "all";
187
187
  return includeKnowledgeBase ? [...new Set([...skills, ...KNOWLEDGE_SKILLS])] : skills;
188
188
  }
189
+ /** A `ModeControl` starting at `initial` (`buildSessionOptions()` returns one for its session). */
189
190
  export function createModeControl(initial) {
190
191
  let current = initial;
191
192
  const switchable = initial === "autonomous" ? ["autonomous"] : ["guided", "interactive"];
@@ -8,8 +8,14 @@
8
8
  * Playwright's browser_* cases) via `createFriendlyToolLabel()`. The kit's own labels are in
9
9
  * the current language (see messages/).
10
10
  */
11
+ /** Cuts `text` to `max` characters, with "…" at the end. */
11
12
  export declare function truncate(text: string, max?: number): string;
13
+ /**
14
+ * Cuts a path to `max` characters from the front, with "…": unlike `truncate()` (which cuts
15
+ * the tail off long freeform text), a path's most useful part, the file name, is at the end.
16
+ */
12
17
  export declare function truncatePath(text: string, max?: number): string;
18
+ /** An agent's labels for its own tools: the tool's short name and input, to a label, or `undefined` for the kit's default. */
13
19
  export type ToolDescriber = (shortName: string, input: Record<string, unknown>) => string | undefined;
14
20
  /**
15
21
  * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
@@ -9,12 +9,14 @@ import { t } from "./messages/index.js";
9
9
  * Playwright's browser_* cases) via `createFriendlyToolLabel()`. The kit's own labels are in
10
10
  * the current language (see messages/).
11
11
  */
12
+ /** Cuts `text` to `max` characters, with "…" at the end. */
12
13
  export function truncate(text, max = 60) {
13
14
  return text.length > max ? `${text.slice(0, max)}…` : text;
14
15
  }
15
- // Unlike truncate() above (which cuts the tail off long freeform text), a path's most
16
- // useful part — the filename — is at the *end*, so this keeps the tail and cuts the
17
- // front instead.
16
+ /**
17
+ * Cuts a path to `max` characters from the front, with "…": unlike `truncate()` (which cuts
18
+ * the tail off long freeform text), a path's most useful part, the file name, is at the end.
19
+ */
18
20
  export function truncatePath(text, max = 60) {
19
21
  return text.length > max ? `…${text.slice(text.length - max + 1)}` : text;
20
22
  }
@@ -1,3 +1,4 @@
1
+ /** The approval tool's texts: its description (when the model should call it) and what it returns when approved or rejected. */
1
2
  export interface HumanApprovalTexts {
2
3
  description: string;
3
4
  approved: string;
@@ -1,3 +1,4 @@
1
+ /** The manual-intervention tool's texts: for the model (`toolDescription`, `confirmedMessage`) and for the person (the checkpoint). */
1
2
  export interface ManualInterventionTexts {
2
3
  toolDescription: string;
3
4
  confirmedMessage: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * as ui from "./tui/ui.js";
2
+ export { setTheme, getTheme, DEFAULT_THEME, type Theme, type ThemeColor } from "./tui/theme.js";
2
3
  export { isExitPromptError } from "./tui/promptErrors.js";
3
4
  export { allowAnyMcpTool } from "./core/mcpPermissions.js";
4
5
  export { createPromptLoader } from "./core/promptTemplate.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { setInteractionPort } from "./core/interaction.js";
2
2
  import { terminalInteractionPort } from "./tui/terminalInteraction.js";
3
3
  export * as ui from "./tui/ui.js";
4
+ export { setTheme, getTheme, DEFAULT_THEME } from "./tui/theme.js";
4
5
  export { isExitPromptError } from "./tui/promptErrors.js";
5
6
  export { allowAnyMcpTool } from "./core/mcpPermissions.js";
6
7
  export { createPromptLoader } from "./core/promptTemplate.js";
@@ -1,6 +1,8 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { type AgentEvent } from "../core/runner.js";
3
+ import { type Theme } from "./theme.js";
3
4
  import { type SessionOpener } from "./runs.js";
5
+ /** Options of the chats (`runChatTui()`, and `runChatInk()` through `InkChatOptions`). */
4
6
  export interface ChatTuiOptions {
5
7
  /**
6
8
  * Turns a tool call into a one-line console label. Defaults to `createFriendlyToolLabel()`
@@ -70,6 +72,12 @@ export interface ChatTuiOptions {
70
72
  * conversation, tool results included and not redacted: keep it out of version control.
71
73
  */
72
74
  runsDir?: string;
75
+ /**
76
+ * Colors for the kit's roles (see `Theme`), on top of the kit's defaults: only the roles
77
+ * given change, e.g. `{ toolResult: "yellow", selection: "#00ff00" }`. One theme per
78
+ * process: without this option, the one already set stays.
79
+ */
80
+ theme?: Partial<Theme>;
73
81
  }
74
82
  interface HistoryEntry {
75
83
  text: string;
@@ -10,6 +10,7 @@ import { isSharedQuestionActive, setSharedReadline } from "./terminalInteraction
10
10
  import * as ui from "./ui.js";
11
11
  import { t } from "../core/messages/index.js";
12
12
  import { applyLanguage } from "./language.js";
13
+ import { applyTheme } from "./theme.js";
13
14
  import { createConsoleRenderer } from "./consoleRenderer.js";
14
15
  import { firstRun, openSession, runDate, runFolderOf, runLabel } from "./runs.js";
15
16
  // Only strips picocolors' own SGR sequences (`\x1b[<codes>m`) — the only kind this file
@@ -106,6 +107,7 @@ export async function drainTurn(events, onEvent) {
106
107
  */
107
108
  export async function runChatTui(options, tuiOptions = {}) {
108
109
  applyLanguage(tuiOptions.language);
110
+ applyTheme(tuiOptions.theme);
109
111
  const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
110
112
  const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
111
113
  const opener = typeof options === "function" ? options : null;
@@ -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;
@@ -9,6 +9,7 @@ import { isFocusReport } from "./terminalStatus.js";
9
9
  import { fitWidth, stripAnsi } from "./lineBuffer.js";
10
10
  import * as ui from "../ui.js";
11
11
  import { t } from "../../core/messages/index.js";
12
+ import { inkColor } from "../theme.js";
12
13
  // Numbered as in the Claude Code CLI's permission prompt; the digits and y/n/q answer too.
13
14
  const decisionOptions = () => [
14
15
  { label: `1. ${t().yes}`, value: "y" },
@@ -17,7 +18,6 @@ const decisionOptions = () => [
17
18
  ];
18
19
  const manualOptions = () => [{ label: `1. ${t().doneContinue}`, value: "continue" }];
19
20
  const DECISION_KEYS = { "1": "y", "2": "n", "3": "q", y: "y", n: "n", q: "q" };
20
- const ACCENT_HEX = "#d77757";
21
21
  /**
22
22
  * The preview's lines, capped so the whole live area stays shorter than the terminal: Ink
23
23
  * clears the entire screen (scrollback included) to redraw anything taller, and a step-gate
@@ -39,7 +39,7 @@ function CheckpointPanel({ checkpoint, renderApproval }) {
39
39
  });
40
40
  const { prompt } = checkpoint;
41
41
  const { stdout } = useStdout();
42
- 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: 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
+ 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) })] }));
43
43
  }
44
44
  function formatTokens(count) {
45
45
  return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
@@ -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. */
@@ -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,6 +13,12 @@ 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>;
14
22
  /**
15
23
  * The language of the kit's texts ("en", "es", "fr", "de"). `--language=<code>` on the
16
24
  * command line wins; without either, the one `buildSessionOptions()` chose from
@@ -18,6 +26,7 @@ export interface ProgressViewOptions extends Omit<ConsoleRendererOptions, "outpu
18
26
  */
19
27
  language?: string;
20
28
  }
29
+ /** A one-shot run's live view: a console renderer drawn with Ink, plus `close()`. */
21
30
  export interface ProgressView extends ConsoleRenderer {
22
31
  /** Removes the live area and restores the previous interaction port; await it before printing anything else. */
23
32
  close(): Promise<void>;
@@ -8,6 +8,8 @@ import { createInkInteraction } from "./inkInteraction.js";
8
8
  import { createSessionModel, liveWidth } from "./sessionModel.js";
9
9
  import { SessionView } from "./SessionView.js";
10
10
  import { applyLanguage } from "../language.js";
11
+ import { applyTheme } from "../theme.js";
12
+ import { KitTheme } from "./inkTheme.js";
11
13
  function ProgressApp({ model, interaction, renderApproval, mode, closed }) {
12
14
  const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
13
15
  // Only a panel reads the keyboard (and so puts stdin in raw mode, where Ctrl+C no longer
@@ -31,6 +33,7 @@ function ProgressApp({ model, interaction, renderApproval, mode, closed }) {
31
33
  */
32
34
  export function createProgressView(options = {}) {
33
35
  applyLanguage(options.language);
36
+ applyTheme(options.theme);
34
37
  if (options.plain || !stdin.isTTY || !stdout.isTTY) {
35
38
  const renderer = createConsoleRenderer(options);
36
39
  return Object.assign(renderer, { close: async () => { } });
@@ -39,7 +42,7 @@ export function createProgressView(options = {}) {
39
42
  const interaction = createInkInteraction((text) => model.note(text));
40
43
  const previousPort = getInteractionPort();
41
44
  setInteractionPort(interaction.port);
42
- 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 }) }));
43
46
  const instance = render(app(false), { exitOnCtrlC: false });
44
47
  model.startTurn();
45
48
  let closing = null;
@@ -7,6 +7,7 @@ import { type RenderApproval } from "./SessionView.js";
7
7
  import type { ToolPhrase } from "./toolGroup.js";
8
8
  import { type HeaderInfo } from "./header.js";
9
9
  export type { HeaderInfo } from "./header.js";
10
+ /** Options of `runChatInk()`: the chat's options plus the Ink-only ones. */
10
11
  export interface InkChatOptions extends ChatTuiOptions {
11
12
  /**
12
13
  * Title, fields and an optional logo for the top of the chat: fixed at the top in full
@@ -13,6 +13,8 @@ import { capHistory, loadHistory, runChatTui, saveHistory, slashCommandToken } f
13
13
  import * as ui from "../ui.js";
14
14
  import { t } from "../../core/messages/index.js";
15
15
  import { applyLanguage } from "../language.js";
16
+ import { applyTheme, inkColor } from "../theme.js";
17
+ import { KitTheme } from "./inkTheme.js";
16
18
  import { firstRun, openSession, runDate, runFolderOf, runLabel } from "../runs.js";
17
19
  import { createInkInteraction } from "./inkInteraction.js";
18
20
  import { PromptInput } from "./PromptInput.js";
@@ -114,7 +116,7 @@ function PickerPanel({ picker }) {
114
116
  picker.resolve(null);
115
117
  });
116
118
  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 })] }));
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 })] }));
118
120
  }
119
121
  function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl: currentModeControl, onInterrupt }) {
120
122
  const chat = useSyncExternalStore(input.subscribe, input.getSnapshot);
@@ -160,7 +162,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
160
162
  onInterrupt();
161
163
  }
162
164
  });
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) }) }))] }) }));
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) }) }))] }) }));
164
166
  }
165
167
  /**
166
168
  * The Ink counterpart of `runChatTui()`, with the same options, session log and history
@@ -183,6 +185,7 @@ export async function runChatInk(options, tuiOptions = {}) {
183
185
  // Warnings about an unsupported language code go into the chat, not behind the full screen.
184
186
  const languageWarnings = [];
185
187
  applyLanguage(tuiOptions.language, (line) => languageWarnings.push(line));
188
+ applyTheme(tuiOptions.theme);
186
189
  const opener = typeof options === "function" ? options : null;
187
190
  const runsDir = opener ? tuiOptions.runsDir : undefined;
188
191
  if (opener && !runsDir)
@@ -331,7 +334,7 @@ export async function runChatInk(options, tuiOptions = {}) {
331
334
  const cursor = tuiOptions.fullscreen ? createCursorController(stdout) : null;
332
335
  const status = tuiOptions.terminalIntegration === false ? null : createTerminalStatus((text) => void stdout.write(text), tuiOptions.header?.title ?? "agent");
333
336
  status?.start();
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 }) }),
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 }) }) }),
335
338
  // In full screen Ink writes through the cursor controller, which places the terminal's cursor.
336
339
  { exitOnCtrlC: false, ...(cursor ? { stdout: cursor.stream } : {}) });
337
340
  try {
@@ -1,4 +1,4 @@
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
4
  import { t } from "../../core/messages/index.js";
@@ -39,7 +39,9 @@ export function resultSummary(result, markdown = false) {
39
39
  if (result.isError)
40
40
  return ui.error(first) + more;
41
41
  // Rendered wide, as one row: the summary line is cut to the room left afterwards.
42
- 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;
43
45
  }
44
46
  /**
45
47
  * The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
@@ -57,7 +59,7 @@ export function toolGroupExpanded(calls, width) {
57
59
  result,
58
60
  ];
59
61
  return [
60
- `${ui.toolBullet("●")} ${fitWidth(call.label, width - 2)}`,
62
+ `${ui.toolBullet("●")} ${fitWidth(ui.toolLabel(call.label), width - 2)}`,
61
63
  ...under.map((line, i) => (line === "" ? "" : ` ${i === 0 ? ui.dim("⎿") : " "} ${line}`)),
62
64
  ];
63
65
  });
@@ -1,3 +1,5 @@
1
+ import { type Theme } from "../theme.js";
2
+ /** The wizard's answers, by step name. */
1
3
  export type WizardAnswers = Record<string, unknown>;
2
4
  /** A value, or one computed from the answers given so far. */
3
5
  type FromAnswers<T> = T | ((answers: WizardAnswers) => T);
@@ -8,6 +10,7 @@ interface BaseStep {
8
10
  /** Skips the step (leaving no answer) when it returns false. */
9
11
  when?: (answers: WizardAnswers) => boolean;
10
12
  }
13
+ /** A choice among `choices`; the answer is the chosen `value`. */
11
14
  export interface SelectStep<T = unknown> extends BaseStep {
12
15
  type: "select";
13
16
  choices: FromAnswers<{
@@ -17,26 +20,37 @@ export interface SelectStep<T = unknown> extends BaseStep {
17
20
  /** Preselects the choice with this value. */
18
21
  default?: FromAnswers<T>;
19
22
  }
23
+ /** A line of text. */
20
24
  export interface InputStep extends BaseStep {
21
25
  type: "input";
22
26
  default?: FromAnswers<string>;
23
27
  /** `true` to accept, or the message to show. */
24
28
  validate?: (value: string, answers: WizardAnswers) => true | string;
25
29
  }
30
+ /** A line of text, masked while typed and in the summary. */
26
31
  export interface PasswordStep extends BaseStep {
27
32
  type: "password";
28
33
  validate?: (value: string, answers: WizardAnswers) => true | string;
29
34
  }
35
+ /** A yes/no question; the answer is a boolean. */
30
36
  export interface ConfirmStep extends BaseStep {
31
37
  type: "confirm";
32
38
  default?: FromAnswers<boolean>;
33
39
  }
40
+ /** One question of `runWizard()`. */
34
41
  export type WizardStep = SelectStep | InputStep | PasswordStep | ConfirmStep;
42
+ /** Options of `runWizard()`. */
35
43
  export interface WizardOptions {
36
44
  /** Printed above the first question. */
37
45
  title?: string;
38
46
  /** Use the plain @inquirer/prompts questions even on a TTY. */
39
47
  plain?: boolean;
48
+ /**
49
+ * Colors for the kit's roles (see `Theme`), on top of the kit's defaults: only the roles
50
+ * given change, e.g. `{ toolResult: "yellow", selection: "#00ff00" }`. One theme per
51
+ * process: without this option, the one already set stays.
52
+ */
53
+ theme?: Partial<Theme>;
40
54
  /**
41
55
  * The language of the kit's texts ("en", "es", "fr", "de"). `--language=<code>` on the
42
56
  * command line wins; without either, the one `buildSessionOptions()` chose from
@@ -9,6 +9,8 @@ import * as inquirer from "@inquirer/prompts";
9
9
  import * as ui from "../ui.js";
10
10
  import { t } from "../../core/messages/index.js";
11
11
  import { applyLanguage } from "../language.js";
12
+ import { applyTheme } from "../theme.js";
13
+ import { KitTheme } from "./inkTheme.js";
12
14
  function resolve(value, answers) {
13
15
  return typeof value === "function" ? value(answers) : value;
14
16
  }
@@ -157,12 +159,13 @@ async function runPlainWizard(steps, title) {
157
159
  */
158
160
  export async function runWizard(steps, options = {}) {
159
161
  applyLanguage(options.language);
162
+ applyTheme(options.theme);
160
163
  if (options.plain || !stdin.isTTY || !stdout.isTTY)
161
164
  return await runPlainWizard(steps, options.title);
162
165
  if (nextIndex(steps, -1, {}) >= steps.length)
163
166
  return {};
164
167
  let result = null;
165
- const app = render(_jsx(Wizard, { steps: steps, title: options.title, onDone: (answers) => (result = answers) }), { exitOnCtrlC: false });
168
+ const app = render(_jsx(KitTheme, { children: _jsx(Wizard, { steps: steps, title: options.title, onDone: (answers) => (result = answers) }) }), { exitOnCtrlC: false });
166
169
  await app.waitUntilExit();
167
170
  if (result === null)
168
171
  throw exitPromptError();
@@ -1,6 +1,8 @@
1
1
  import readline from "node:readline/promises";
2
2
  import type { InteractionPort } from "../core/interaction.js";
3
+ /** Registers the chat's `readline` interface, so checkpoints ask on it instead of opening a second one (`null` to unregister). */
3
4
  export declare function setSharedReadline(rl: readline.Interface | null): void;
5
+ /** The registered `readline` interface, or `null`. */
4
6
  export declare function getSharedReadline(): readline.Interface | null;
5
7
  /**
6
8
  * Asks `question` on the shared interface, flagging it as in progress meanwhile — so the
@@ -14,9 +14,11 @@ import { t } from "../core/messages/index.js";
14
14
  * interface here, the port reuses it (creating or closing nothing) whenever it's available.
15
15
  */
16
16
  let sharedReadline = null;
17
+ /** Registers the chat's `readline` interface, so checkpoints ask on it instead of opening a second one (`null` to unregister). */
17
18
  export function setSharedReadline(rl) {
18
19
  sharedReadline = rl;
19
20
  }
21
+ /** The registered `readline` interface, or `null`. */
20
22
  export function getSharedReadline() {
21
23
  return sharedReadline;
22
24
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A color for one role of the theme: a color name (`"gray"`, `"cyanBright"`: picocolors' and
3
+ * Ink's names), a hex (`"#ff8800"`), or a function that styles the text itself (bold, a
4
+ * background...). Roles drawn by Ink props (`border`, `selection`, the panels' `accent`) need
5
+ * a name or a hex: a function there falls back to the default color.
6
+ */
7
+ export type ThemeColor = string | ((text: string) => string);
8
+ /** The terminal UI's colors, by what they're for. */
9
+ export interface Theme {
10
+ /** The agent's replies and its label. */
11
+ agent: ThemeColor;
12
+ /** The human's prompt label. */
13
+ user: ThemeColor;
14
+ /** The background of the human's lines in the Ink chat (a name or a hex). */
15
+ userBar: ThemeColor;
16
+ /** The border of the approval and manual-intervention panels (a name or a hex). */
17
+ accent: ThemeColor;
18
+ /** The spinner with what the agent is doing, and the mode in the status bar. */
19
+ working: ThemeColor;
20
+ /** Inline code and code blocks in the agent's replies. */
21
+ code: ThemeColor;
22
+ /** The `●` before each tool call. */
23
+ toolBullet: ThemeColor;
24
+ /** A tool call's label (`● Reading notes.md`). */
25
+ toolLabel: ThemeColor;
26
+ /** The one-line result under a tool call (`⎿ …`); an error is `error` instead. */
27
+ toolResult: ThemeColor;
28
+ /** The focused option of a choice list (approval panels, /resume, the wizard), in bold. */
29
+ selection: ThemeColor;
30
+ /** The frame around the prompt and the /resume list. */
31
+ border: ThemeColor;
32
+ /** Secondary text: notices, summaries, hints. */
33
+ dim: ThemeColor;
34
+ /** Titles (checkpoints, the wizard's questions). */
35
+ heading: ThemeColor;
36
+ /** The plain console's `[action]` lines. */
37
+ action: ThemeColor;
38
+ success: ThemeColor;
39
+ warn: ThemeColor;
40
+ error: ThemeColor;
41
+ }
42
+ /** The kit's own look. */
43
+ export declare const DEFAULT_THEME: Readonly<Theme>;
44
+ /** The kit's default theme with `overrides` on top: only the roles given change. */
45
+ export declare function setTheme(overrides?: Partial<Theme>): void;
46
+ /** The theme in use right now. */
47
+ export declare function getTheme(): Readonly<Theme>;
48
+ /** Changes the theme when an entry point was given one; without it, keeps the current one. */
49
+ export declare function applyTheme(overrides: Partial<Theme> | undefined): void;
50
+ /**
51
+ * `text` in `color`: a function is applied as given; a name or a hex becomes the terminal's
52
+ * escape codes, or nothing when the terminal has no color (`enabled`). `background` for a
53
+ * background color (a name becomes its `bg` form).
54
+ */
55
+ export declare function paint(color: ThemeColor, text: string, options?: {
56
+ background?: boolean;
57
+ enabled?: boolean;
58
+ }): string;
59
+ /** A role's color as an Ink prop (a name or a hex); a function falls back to the default's. */
60
+ export declare function inkColor(role: "accent" | "border" | "selection"): string;
@@ -0,0 +1,65 @@
1
+ import pc from "picocolors";
2
+ /** The kit's own look. */
3
+ export const DEFAULT_THEME = {
4
+ agent: "cyanBright",
5
+ user: "white",
6
+ userBar: "#373737",
7
+ accent: "#d77757",
8
+ working: "#89b4fa",
9
+ code: "#b1b9f9",
10
+ toolBullet: "green",
11
+ toolLabel: (text) => pc.dim(text),
12
+ toolResult: (text) => pc.dim(text),
13
+ selection: "#d77757",
14
+ border: "gray",
15
+ dim: (text) => pc.dim(text),
16
+ heading: (text) => pc.bold(text),
17
+ action: "magenta",
18
+ success: "green",
19
+ warn: "yellow",
20
+ error: "red",
21
+ };
22
+ // One theme per process, like the language (ADR-019).
23
+ let current = { ...DEFAULT_THEME };
24
+ /** The kit's default theme with `overrides` on top: only the roles given change. */
25
+ export function setTheme(overrides = {}) {
26
+ current = { ...DEFAULT_THEME, ...overrides };
27
+ }
28
+ /** The theme in use right now. */
29
+ export function getTheme() {
30
+ return current;
31
+ }
32
+ /** Changes the theme when an entry point was given one; without it, keeps the current one. */
33
+ export function applyTheme(overrides) {
34
+ if (overrides)
35
+ setTheme(overrides);
36
+ }
37
+ const HEX = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
38
+ const COLORS = pc.createColors(true);
39
+ const NAMES = new Set(Object.keys(pc).filter((key) => typeof pc[key] === "function" && key !== "createColors"));
40
+ /**
41
+ * `text` in `color`: a function is applied as given; a name or a hex becomes the terminal's
42
+ * escape codes, or nothing when the terminal has no color (`enabled`). `background` for a
43
+ * background color (a name becomes its `bg` form).
44
+ */
45
+ export function paint(color, text, options = {}) {
46
+ if (typeof color === "function")
47
+ return color(text);
48
+ const enabled = options.enabled ?? pc.isColorSupported;
49
+ if (!enabled)
50
+ return text;
51
+ const hex = HEX.exec(color);
52
+ if (hex) {
53
+ const [r, g, b] = hex.slice(1).map((part) => parseInt(part, 16));
54
+ return options.background ? `\x1b[48;2;${r};${g};${b}m${text}\x1b[49m` : `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
55
+ }
56
+ const name = options.background ? `bg${color.charAt(0).toUpperCase()}${color.slice(1)}` : color;
57
+ if (!NAMES.has(name))
58
+ return text;
59
+ return COLORS[name](text);
60
+ }
61
+ /** A role's color as an Ink prop (a name or a hex); a function falls back to the default's. */
62
+ export function inkColor(role) {
63
+ const color = current[role];
64
+ return typeof color === "string" ? color : DEFAULT_THEME[role];
65
+ }
package/dist/tui/ui.d.ts CHANGED
@@ -1,13 +1,25 @@
1
- /** Color palette for a consistent console look across whatever CLI is built on this kit. */
1
+ /**
2
+ * The terminal palette: one function per theme role, drawing text in the current theme.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ /** The agent's label and texts (`agent` role). */
2
7
  export declare const agent: (s: string) => string;
8
+ /** The person's prompt label (`user` role). */
3
9
  export declare const user: (s: string) => string;
10
+ /** The plain console's `[action]` lines (`action` role). */
4
11
  export declare const action: (s: string) => string;
12
+ /** Titles (`heading` role, bold by default). */
5
13
  export declare const heading: (s: string) => string;
14
+ /** Good outcomes (`success` role). */
6
15
  export declare const success: (s: string) => string;
16
+ /** Warnings and notices (`warn` role). */
7
17
  export declare const warn: (s: string) => string;
18
+ /** Errors (`error` role). */
8
19
  export declare const error: (s: string) => string;
20
+ /** Secondary text (`dim` role). */
9
21
  export declare const dim: (s: string) => string;
10
- /** The orange accent: spinner, turn summary, mode. */
22
+ /** The accent (the approval panels' border). */
11
23
  export declare const accent: (s: string) => string;
12
24
  /** The spinner and what the agent is doing. */
13
25
  export declare const working: (s: string) => string;
@@ -15,7 +27,15 @@ export declare const working: (s: string) => string;
15
27
  export declare const code: (s: string) => string;
16
28
  /** The background of the human's lines. */
17
29
  export declare const userBar: (s: string) => string;
30
+ /** The `●` before a tool call (`toolBullet` role). */
31
+ export declare const toolBullet: (s: string) => string;
32
+ /** A tool call's label. */
33
+ export declare const toolLabel: (s: string) => string;
34
+ /** The one-line result under a tool call. */
35
+ export declare const toolResult: (s: string) => string;
36
+ /** Bold, whatever the theme. */
18
37
  export declare const bold: (s: string) => string;
38
+ /** Italics, whatever the theme. */
19
39
  export declare const italic: (s: string) => string;
40
+ /** Strikethrough, whatever the theme. */
20
41
  export declare const strike: (s: string) => string;
21
- export declare const toolBullet: (s: string) => string;
package/dist/tui/ui.js CHANGED
@@ -1,25 +1,49 @@
1
+ /**
2
+ * The terminal palette: one function per theme role, drawing text in the current theme.
3
+ *
4
+ * @packageDocumentation
5
+ */
1
6
  import pc from "picocolors";
2
- /** Color palette for a consistent console look across whatever CLI is built on this kit. */
3
- export const agent = (s) => pc.cyanBright(s);
4
- export const user = (s) => pc.white(s);
5
- export const action = (s) => pc.magenta(s);
6
- export const heading = (s) => pc.bold(s);
7
- export const success = (s) => pc.green(s);
8
- export const warn = (s) => pc.yellow(s);
9
- export const error = (s) => pc.red(s);
10
- export const dim = (s) => pc.dim(s);
11
- // The Claude Code-like look of the Ink views (see the feature ink-claude-style), in 24-bit
12
- // color when the terminal supports color at all, like the rest of this palette.
13
- const rgb = (r, g, b) => (s) => pc.isColorSupported ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : s;
14
- /** The orange accent: spinner, turn summary, mode. */
15
- export const accent = rgb(215, 119, 87);
7
+ import { getTheme, paint } from "./theme.js";
8
+ /*
9
+ * Color palette for a consistent console look across whatever CLI is built on this kit.
10
+ * Each function draws its role in the current theme (see theme.ts), so an agent's theme
11
+ * reaches everything that uses them, its own texts included.
12
+ */
13
+ /** The agent's label and texts (`agent` role). */
14
+ export const agent = (s) => paint(getTheme().agent, s);
15
+ /** The person's prompt label (`user` role). */
16
+ export const user = (s) => paint(getTheme().user, s);
17
+ /** The plain console's `[action]` lines (`action` role). */
18
+ export const action = (s) => paint(getTheme().action, s);
19
+ /** Titles (`heading` role, bold by default). */
20
+ export const heading = (s) => paint(getTheme().heading, s);
21
+ /** Good outcomes (`success` role). */
22
+ export const success = (s) => paint(getTheme().success, s);
23
+ /** Warnings and notices (`warn` role). */
24
+ export const warn = (s) => paint(getTheme().warn, s);
25
+ /** Errors (`error` role). */
26
+ export const error = (s) => paint(getTheme().error, s);
27
+ /** Secondary text (`dim` role). */
28
+ export const dim = (s) => paint(getTheme().dim, s);
29
+ // The Claude Code-like look of the Ink views (see the feature ink-claude-style).
30
+ /** The accent (the approval panels' border). */
31
+ export const accent = (s) => paint(getTheme().accent, s);
16
32
  /** The spinner and what the agent is doing. */
17
- export const working = rgb(137, 180, 250);
33
+ export const working = (s) => paint(getTheme().working, s);
18
34
  /** Inline code and code blocks. */
19
- export const code = rgb(177, 185, 249);
35
+ export const code = (s) => paint(getTheme().code, s);
20
36
  /** The background of the human's lines. */
21
- export const userBar = (s) => (pc.isColorSupported ? `\x1b[48;2;55;55;55m${s}\x1b[49m` : s);
37
+ export const userBar = (s) => paint(getTheme().userBar, s, { background: true });
38
+ /** The `●` before a tool call (`toolBullet` role). */
39
+ export const toolBullet = (s) => paint(getTheme().toolBullet, s);
40
+ /** A tool call's label. */
41
+ export const toolLabel = (s) => paint(getTheme().toolLabel, s);
42
+ /** The one-line result under a tool call. */
43
+ export const toolResult = (s) => paint(getTheme().toolResult, s);
44
+ /** Bold, whatever the theme. */
22
45
  export const bold = (s) => pc.bold(s);
46
+ /** Italics, whatever the theme. */
23
47
  export const italic = (s) => pc.italic(s);
48
+ /** Strikethrough, whatever the theme. */
24
49
  export const strike = (s) => pc.strikethrough(s);
25
- export const toolBullet = (s) => pc.green(s);
package/package.json CHANGED
@@ -1,75 +1,79 @@
1
- {
2
- "name": "@falkenslab/agent-kit",
3
- "version": "0.12.0",
4
- "description": "Library for building an agent on top of the Claude Agent SDK",
5
- "author": "Francisco Vargas Ruiz",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/falkenslab/agent-kit.git"
10
- },
11
- "homepage": "https://github.com/falkenslab/agent-kit#readme",
12
- "bugs": {
13
- "url": "https://github.com/falkenslab/agent-kit/issues"
14
- },
15
- "keywords": [
16
- "claude",
17
- "claude-agent-sdk",
18
- "ai-agent",
19
- "agent",
20
- "anthropic",
21
- "ink",
22
- "tui",
23
- "cli",
24
- "human-in-the-loop",
25
- "mcp"
26
- ],
27
- "type": "module",
28
- "engines": {
29
- "node": ">=20"
30
- },
31
- "main": "./dist/index.js",
32
- "types": "./dist/index.d.ts",
33
- "files": [
34
- "dist",
35
- "assets"
36
- ],
37
- "scripts": {
38
- "build": "tsc",
39
- "typecheck": "tsc --noEmit",
40
- "test": "tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
41
- "lint": "eslint .",
42
- "prepare": "npm run build",
43
- "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build"
44
- },
45
- "dependencies": {
46
- "@anthropic-ai/claude-agent-sdk": "^0.3.234",
47
- "@inkjs/ui": "^2.0.0",
48
- "@inquirer/prompts": "^8.5.2",
49
- "@types/react": "^19.3.0",
50
- "ink": "^6.8.0",
51
- "marked": "^18.0.14",
52
- "picocolors": "^1.1.1",
53
- "react": "^19.3.0",
54
- "slice-ansi": "^8.0.0",
55
- "string-width": "^8.3.0",
56
- "wrap-ansi": "^9.0.2",
57
- "zod": "^4.4.3"
58
- },
59
- "devDependencies": {
60
- "@eslint/js": "^10.0.1",
61
- "@types/node": "^24.0.0",
62
- "eslint": "^10.9.1",
63
- "globals": "^17.11.0",
64
- "ink-testing-library": "^4.0.0",
65
- "tsx": "^4.19.0",
66
- "typescript": "^5.7.0",
67
- "typescript-eslint": "^8.68.0"
68
- },
69
- "allowScripts": {
70
- "esbuild@0.28.2": true
71
- },
72
- "publishConfig": {
73
- "access": "public"
74
- }
75
- }
1
+ {
2
+ "name": "@falkenslab/agent-kit",
3
+ "version": "0.13.0",
4
+ "description": "Library for building an agent on top of the Claude Agent SDK",
5
+ "author": "Francisco Vargas Ruiz",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/falkenslab/agent-kit.git"
10
+ },
11
+ "homepage": "https://github.com/falkenslab/agent-kit#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/falkenslab/agent-kit/issues"
14
+ },
15
+ "keywords": [
16
+ "claude",
17
+ "claude-agent-sdk",
18
+ "ai-agent",
19
+ "agent",
20
+ "anthropic",
21
+ "ink",
22
+ "tui",
23
+ "cli",
24
+ "human-in-the-loop",
25
+ "mcp"
26
+ ],
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "assets"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
41
+ "lint": "eslint .",
42
+ "docs:install": "npm --prefix docs ci",
43
+ "docs:start": "npm --prefix docs start",
44
+ "docs:build": "npm --prefix docs run build",
45
+ "docs:version": "npm --prefix docs run docs:version --",
46
+ "prepare": "npm run build",
47
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build"
48
+ },
49
+ "dependencies": {
50
+ "@anthropic-ai/claude-agent-sdk": "^0.3.234",
51
+ "@inkjs/ui": "^2.0.0",
52
+ "@inquirer/prompts": "^8.5.2",
53
+ "@types/react": "^19.3.0",
54
+ "ink": "^6.8.0",
55
+ "marked": "^18.0.14",
56
+ "picocolors": "^1.1.1",
57
+ "react": "^19.3.0",
58
+ "slice-ansi": "^8.0.0",
59
+ "string-width": "^8.3.0",
60
+ "wrap-ansi": "^9.0.2",
61
+ "zod": "^4.4.3"
62
+ },
63
+ "devDependencies": {
64
+ "@eslint/js": "^10.0.1",
65
+ "@types/node": "^24.0.0",
66
+ "eslint": "^10.9.1",
67
+ "globals": "^17.11.0",
68
+ "ink-testing-library": "^4.0.0",
69
+ "tsx": "^4.19.0",
70
+ "typescript": "^5.7.0",
71
+ "typescript-eslint": "^8.68.0"
72
+ },
73
+ "allowScripts": {
74
+ "esbuild@0.28.2": true
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ }
79
+ }