@falkenslab/agent-kit 0.10.1

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/assets/knowledge-plugin/.claude-plugin/plugin.json +4 -0
  4. package/assets/knowledge-plugin/commands/ingest.md +7 -0
  5. package/assets/knowledge-plugin/commands/lint.md +5 -0
  6. package/assets/knowledge-plugin/commands/query.md +5 -0
  7. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +42 -0
  8. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +26 -0
  9. package/assets/knowledge-plugin/skills/knowledge-pages/SKILL.md +137 -0
  10. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +14 -0
  11. package/dist/core/agentSpec.d.ts +132 -0
  12. package/dist/core/agentSpec.js +1 -0
  13. package/dist/core/claudeAuth.d.ts +24 -0
  14. package/dist/core/claudeAuth.js +27 -0
  15. package/dist/core/hooks/fileScopeGate.d.ts +33 -0
  16. package/dist/core/hooks/fileScopeGate.js +68 -0
  17. package/dist/core/hooks/humanInput.d.ts +15 -0
  18. package/dist/core/hooks/humanInput.js +52 -0
  19. package/dist/core/hooks/stepGate.d.ts +11 -0
  20. package/dist/core/hooks/stepGate.js +47 -0
  21. package/dist/core/hooks/subagentBashGate.d.ts +28 -0
  22. package/dist/core/hooks/subagentBashGate.js +40 -0
  23. package/dist/core/hooks/subagentForegroundGate.d.ts +18 -0
  24. package/dist/core/hooks/subagentForegroundGate.js +34 -0
  25. package/dist/core/hooks/subagentTypeGate.d.ts +16 -0
  26. package/dist/core/hooks/subagentTypeGate.js +33 -0
  27. package/dist/core/hooks/transcriptLogger.d.ts +14 -0
  28. package/dist/core/hooks/transcriptLogger.js +66 -0
  29. package/dist/core/interaction.d.ts +29 -0
  30. package/dist/core/interaction.js +7 -0
  31. package/dist/core/knowledge.d.ts +15 -0
  32. package/dist/core/knowledge.js +54 -0
  33. package/dist/core/mcpPermissions.d.ts +23 -0
  34. package/dist/core/mcpPermissions.js +47 -0
  35. package/dist/core/promptTemplate.d.ts +6 -0
  36. package/dist/core/promptTemplate.js +20 -0
  37. package/dist/core/runner.d.ts +137 -0
  38. package/dist/core/runner.js +115 -0
  39. package/dist/core/session.d.ts +63 -0
  40. package/dist/core/session.js +222 -0
  41. package/dist/core/toolLabels.d.ts +23 -0
  42. package/dist/core/toolLabels.js +114 -0
  43. package/dist/core/tools/humanApproval.d.ts +14 -0
  44. package/dist/core/tools/humanApproval.js +37 -0
  45. package/dist/core/tools/manualLogin.d.ts +16 -0
  46. package/dist/core/tools/manualLogin.js +27 -0
  47. package/dist/core/tools/saveToSources.d.ts +10 -0
  48. package/dist/core/tools/saveToSources.js +105 -0
  49. package/dist/index.d.ts +31 -0
  50. package/dist/index.js +32 -0
  51. package/dist/tui/chatTui.d.ts +105 -0
  52. package/dist/tui/chatTui.js +265 -0
  53. package/dist/tui/claudeAuth.d.ts +19 -0
  54. package/dist/tui/claudeAuth.js +96 -0
  55. package/dist/tui/consoleRenderer.d.ts +32 -0
  56. package/dist/tui/consoleRenderer.js +72 -0
  57. package/dist/tui/ink/PromptInput.d.ts +40 -0
  58. package/dist/tui/ink/PromptInput.js +333 -0
  59. package/dist/tui/ink/SessionView.d.ts +42 -0
  60. package/dist/tui/ink/SessionView.js +223 -0
  61. package/dist/tui/ink/fileMentions.d.ts +13 -0
  62. package/dist/tui/ink/fileMentions.js +50 -0
  63. package/dist/tui/ink/fullscreen.d.ts +79 -0
  64. package/dist/tui/ink/fullscreen.js +175 -0
  65. package/dist/tui/ink/header.d.ts +17 -0
  66. package/dist/tui/ink/header.js +24 -0
  67. package/dist/tui/ink/inkInteraction.d.ts +22 -0
  68. package/dist/tui/ink/inkInteraction.js +64 -0
  69. package/dist/tui/ink/lineBuffer.d.ts +28 -0
  70. package/dist/tui/ink/lineBuffer.js +87 -0
  71. package/dist/tui/ink/markdown.d.ts +12 -0
  72. package/dist/tui/ink/markdown.js +148 -0
  73. package/dist/tui/ink/progressView.d.ts +27 -0
  74. package/dist/tui/ink/progressView.js +65 -0
  75. package/dist/tui/ink/promptEditing.d.ts +54 -0
  76. package/dist/tui/ink/promptEditing.js +123 -0
  77. package/dist/tui/ink/runChatInk.d.ts +103 -0
  78. package/dist/tui/ink/runChatInk.js +298 -0
  79. package/dist/tui/ink/sessionModel.d.ts +85 -0
  80. package/dist/tui/ink/sessionModel.js +262 -0
  81. package/dist/tui/ink/terminalCursor.d.ts +32 -0
  82. package/dist/tui/ink/terminalCursor.js +68 -0
  83. package/dist/tui/ink/terminalStatus.d.ts +20 -0
  84. package/dist/tui/ink/terminalStatus.js +55 -0
  85. package/dist/tui/ink/toolGroup.d.ts +36 -0
  86. package/dist/tui/ink/toolGroup.js +77 -0
  87. package/dist/tui/ink/wizard.d.ts +62 -0
  88. package/dist/tui/ink/wizard.js +167 -0
  89. package/dist/tui/promptErrors.d.ts +2 -0
  90. package/dist/tui/promptErrors.js +4 -0
  91. package/dist/tui/terminalInteraction.d.ts +18 -0
  92. package/dist/tui/terminalInteraction.js +85 -0
  93. package/dist/tui/ui.d.ts +21 -0
  94. package/dist/tui/ui.js +25 -0
  95. package/package.json +75 -0
@@ -0,0 +1,27 @@
1
+ import type { Mode } from "../../core/agentSpec.js";
2
+ import { type ConsoleRenderer, type ConsoleRendererOptions } from "../consoleRenderer.js";
3
+ import { type RenderApproval } from "./SessionView.js";
4
+ import type { ToolPhrase } from "./toolGroup.js";
5
+ export interface ProgressViewOptions extends Omit<ConsoleRendererOptions, "output"> {
6
+ /** How one of the agent's own tools counts in a folded group's summary (see `InkChatOptions.toolPhrase`). */
7
+ toolPhrase?: (toolName: string) => ToolPhrase | undefined;
8
+ /** Replaces the default preview in the approval and manual-intervention panels. */
9
+ renderApproval?: RenderApproval;
10
+ /** Shown in the status bar. */
11
+ mode?: Mode;
12
+ /** Use the plain console renderer even on a TTY. */
13
+ plain?: boolean;
14
+ }
15
+ export interface ProgressView extends ConsoleRenderer {
16
+ /** Removes the live area and restores the previous interaction port; await it before printing anything else. */
17
+ close(): Promise<void>;
18
+ }
19
+ /**
20
+ * The Ink counterpart of `createConsoleRenderer()` for one-shot ("run"-style) sessions: the
21
+ * same methods and the same text (so `onWrite` logs exactly what the console version
22
+ * would), plus a spinner with the current action, approval panels and a status bar.
23
+ * Checkpoints go through an Ink `InteractionPort` until `close()`.
24
+ *
25
+ * Without a TTY or with `plain`, it is `createConsoleRenderer()` with a no-op `close()`.
26
+ */
27
+ export declare function createProgressView(options?: ProgressViewOptions): ProgressView;
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { stdin, stdout } from "node:process";
3
+ import { useSyncExternalStore } from "react";
4
+ import { render, useInput } from "ink";
5
+ import { getInteractionPort, setInteractionPort } from "../../core/interaction.js";
6
+ import { createConsoleRenderer } from "../consoleRenderer.js";
7
+ import { createInkInteraction } from "./inkInteraction.js";
8
+ import { createSessionModel, liveWidth } from "./sessionModel.js";
9
+ import { SessionView } from "./SessionView.js";
10
+ function ProgressApp({ model, interaction, renderApproval, mode, closed }) {
11
+ const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
12
+ // Only a panel reads the keyboard (and so puts stdin in raw mode, where Ctrl+C no longer
13
+ // raises SIGINT): there, Ctrl+C stops the checkpoint and is passed on as the SIGINT the
14
+ // caller's own handler expects.
15
+ useInput((text, key) => {
16
+ if (!(key.ctrl && text === "c") || !checkpoint)
17
+ return;
18
+ checkpoint.answer(checkpoint.kind === "decision" ? "q" : "");
19
+ process.kill(process.pid, "SIGINT");
20
+ }, { isActive: checkpoint !== null });
21
+ return _jsx(SessionView, { model: model, interaction: interaction, renderApproval: renderApproval, mode: mode, closed: closed });
22
+ }
23
+ /**
24
+ * The Ink counterpart of `createConsoleRenderer()` for one-shot ("run"-style) sessions: the
25
+ * same methods and the same text (so `onWrite` logs exactly what the console version
26
+ * would), plus a spinner with the current action, approval panels and a status bar.
27
+ * Checkpoints go through an Ink `InteractionPort` until `close()`.
28
+ *
29
+ * Without a TTY or with `plain`, it is `createConsoleRenderer()` with a no-op `close()`.
30
+ */
31
+ export function createProgressView(options = {}) {
32
+ if (options.plain || !stdin.isTTY || !stdout.isTTY) {
33
+ const renderer = createConsoleRenderer(options);
34
+ return Object.assign(renderer, { close: async () => { } });
35
+ }
36
+ const model = createSessionModel({ ...options, width: () => liveWidth(stdout.columns) });
37
+ const interaction = createInkInteraction((text) => model.note(text));
38
+ const previousPort = getInteractionPort();
39
+ setInteractionPort(interaction.port);
40
+ const app = (closed) => (_jsx(ProgressApp, { model: model, interaction: interaction, renderApproval: options.renderApproval, mode: options.mode, closed: closed }));
41
+ const instance = render(app(false), { exitOnCtrlC: false });
42
+ model.startTurn();
43
+ let closing = null;
44
+ return {
45
+ render: model.render,
46
+ write: (text) => model.renderer.write(text),
47
+ writeLine: (text) => model.renderer.writeLine(text),
48
+ endLine: () => model.renderer.endLine(),
49
+ startTurn: () => model.startTurn(),
50
+ get atLineStart() {
51
+ return model.renderer.atLineStart;
52
+ },
53
+ close() {
54
+ closing ??= (async () => {
55
+ setInteractionPort(previousPort);
56
+ model.endTurn();
57
+ instance.rerender(app(true));
58
+ await new Promise((resolve) => setTimeout(resolve, 0));
59
+ instance.unmount();
60
+ await instance.waitUntilExit();
61
+ })();
62
+ return closing;
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,54 @@
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
+ export interface EditState {
7
+ value: string;
8
+ cursor: number;
9
+ }
10
+ export declare function insert({ value, cursor }: EditState, text: string): EditState;
11
+ export declare function deleteBack({ value, cursor }: EditState): EditState;
12
+ /** Ctrl+W: deletes the word before the cursor. */
13
+ export declare function deleteWordBack({ value, cursor }: EditState): EditState;
14
+ /** Ctrl+K: deletes from the cursor to the end of its line. */
15
+ export declare function killToLineEnd({ value, cursor }: EditState): EditState;
16
+ export declare const wordLeft: ({ value, cursor }: EditState) => EditState;
17
+ export declare const wordRight: ({ value, cursor }: EditState) => EditState;
18
+ /** Home / Ctrl+A and End / Ctrl+E act on the cursor's line. */
19
+ export declare const toLineStart: ({ value, cursor }: EditState) => EditState;
20
+ export declare const toLineEnd: ({ value, cursor }: EditState) => EditState;
21
+ /** The cursor's line and column (0-based, in UTF-16 units). */
22
+ export declare function cursorLine({ value, cursor }: EditState): {
23
+ line: number;
24
+ column: number;
25
+ };
26
+ /**
27
+ * ↑/↓ inside a multi-line prompt: the same column on the line above or below (or its end
28
+ * if shorter), or null on the first/last line, where the arrows go to the history instead.
29
+ */
30
+ export declare function moveLine(state: EditState, direction: -1 | 1): EditState | null;
31
+ /**
32
+ * Enter after a backslash, as in a shell, continues the prompt on a new line: the
33
+ * backslash becomes the line break. Null when there's no backslash right before the cursor.
34
+ */
35
+ export declare function continueLine(state: EditState): EditState | null;
36
+ /** Pasted blocks, shown in the prompt as a short token and expanded when it's sent. */
37
+ export interface PasteRegistry {
38
+ /** Stores `text` and returns the token to insert in its place. */
39
+ add(text: string): string;
40
+ /** Adds `more` to the paste behind `token` (the same paste arriving in pieces); returns its new token. */
41
+ extend(token: string, more: string): string;
42
+ /** `value` with every token replaced by its text. */
43
+ expand(value: string): string;
44
+ }
45
+ /** Normalizes a pasted chunk's line breaks (terminals send "\r" or "\r\n"). */
46
+ export declare function normalizePaste(text: string): string;
47
+ /** A paste worth folding into a token: more than one line. */
48
+ export declare function isBlockPaste(text: string): boolean;
49
+ export declare function createPasteRegistry(): PasteRegistry;
50
+ /**
51
+ * Ctrl+R: the most recent history entry containing `query`, searching back from before
52
+ * `from` (exclusive; history is oldest first). Returns its index, or -1.
53
+ */
54
+ export declare function searchHistory(history: readonly string[], query: string, from?: number): number;
@@ -0,0 +1,123 @@
1
+ export function insert({ value, cursor }, text) {
2
+ return { value: value.slice(0, cursor) + text + value.slice(cursor), cursor: cursor + text.length };
3
+ }
4
+ export function deleteBack({ value, cursor }) {
5
+ if (cursor === 0)
6
+ return { value, cursor };
7
+ return { value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 };
8
+ }
9
+ const isSpace = (char) => char !== undefined && /\s/.test(char);
10
+ /** Where the word before the cursor starts (spaces right before it skipped). */
11
+ function wordStartBefore(value, cursor) {
12
+ let i = cursor;
13
+ while (i > 0 && isSpace(value[i - 1]))
14
+ i--;
15
+ while (i > 0 && !isSpace(value[i - 1]))
16
+ i--;
17
+ return i;
18
+ }
19
+ /** Where the word after the cursor ends (spaces right after it skipped). */
20
+ function wordEndAfter(value, cursor) {
21
+ let i = cursor;
22
+ while (i < value.length && isSpace(value[i]))
23
+ i++;
24
+ while (i < value.length && !isSpace(value[i]))
25
+ i++;
26
+ return i;
27
+ }
28
+ /** Ctrl+W: deletes the word before the cursor. */
29
+ export function deleteWordBack({ value, cursor }) {
30
+ const start = wordStartBefore(value, cursor);
31
+ return { value: value.slice(0, start) + value.slice(cursor), cursor: start };
32
+ }
33
+ /** Ctrl+K: deletes from the cursor to the end of its line. */
34
+ export function killToLineEnd({ value, cursor }) {
35
+ const end = lineEnd(value, cursor);
36
+ return { value: value.slice(0, cursor) + value.slice(end), cursor };
37
+ }
38
+ export const wordLeft = ({ value, cursor }) => ({ value, cursor: wordStartBefore(value, cursor) });
39
+ export const wordRight = ({ value, cursor }) => ({ value, cursor: wordEndAfter(value, cursor) });
40
+ function lineStart(value, cursor) {
41
+ return value.lastIndexOf("\n", cursor - 1) + 1;
42
+ }
43
+ function lineEnd(value, cursor) {
44
+ const next = value.indexOf("\n", cursor);
45
+ return next === -1 ? value.length : next;
46
+ }
47
+ /** Home / Ctrl+A and End / Ctrl+E act on the cursor's line. */
48
+ export const toLineStart = ({ value, cursor }) => ({ value, cursor: lineStart(value, cursor) });
49
+ export const toLineEnd = ({ value, cursor }) => ({ value, cursor: lineEnd(value, cursor) });
50
+ /** The cursor's line and column (0-based, in UTF-16 units). */
51
+ export function cursorLine({ value, cursor }) {
52
+ const before = value.slice(0, cursor).split("\n");
53
+ return { line: before.length - 1, column: before.at(-1)?.length ?? 0 };
54
+ }
55
+ /**
56
+ * ↑/↓ inside a multi-line prompt: the same column on the line above or below (or its end
57
+ * if shorter), or null on the first/last line, where the arrows go to the history instead.
58
+ */
59
+ export function moveLine(state, direction) {
60
+ const lines = state.value.split("\n");
61
+ const { line, column } = cursorLine(state);
62
+ const target = line + direction;
63
+ if (target < 0 || target >= lines.length)
64
+ return null;
65
+ const offset = lines.slice(0, target).reduce((sum, text) => sum + text.length + 1, 0);
66
+ return { value: state.value, cursor: offset + Math.min(column, lines[target].length) };
67
+ }
68
+ /**
69
+ * Enter after a backslash, as in a shell, continues the prompt on a new line: the
70
+ * backslash becomes the line break. Null when there's no backslash right before the cursor.
71
+ */
72
+ export function continueLine(state) {
73
+ if (state.value[state.cursor - 1] !== "\\")
74
+ return null;
75
+ return insert(deleteBack(state), "\n");
76
+ }
77
+ const PASTE_TOKEN = /\[Pasted text #(\d+) \+\d+ lines?\]/g;
78
+ function pasteToken(id, text) {
79
+ const extra = text.split("\n").length - 1;
80
+ return `[Pasted text #${id} +${extra} ${extra === 1 ? "line" : "lines"}]`;
81
+ }
82
+ /** Normalizes a pasted chunk's line breaks (terminals send "\r" or "\r\n"). */
83
+ export function normalizePaste(text) {
84
+ return text.replace(/\r\n?/g, "\n");
85
+ }
86
+ /** A paste worth folding into a token: more than one line. */
87
+ export function isBlockPaste(text) {
88
+ return normalizePaste(text).includes("\n");
89
+ }
90
+ export function createPasteRegistry() {
91
+ const pastes = new Map();
92
+ let nextId = 1;
93
+ return {
94
+ add(text) {
95
+ const id = nextId++;
96
+ pastes.set(id, text);
97
+ return pasteToken(id, text);
98
+ },
99
+ extend(token, more) {
100
+ const id = Number(/#(\d+)/.exec(token)?.[1]);
101
+ const text = (pastes.get(id) ?? "") + more;
102
+ pastes.set(id, text);
103
+ return pasteToken(id, text);
104
+ },
105
+ expand(value) {
106
+ return value.replace(PASTE_TOKEN, (token, id) => pastes.get(Number(id)) ?? token);
107
+ },
108
+ };
109
+ }
110
+ /**
111
+ * Ctrl+R: the most recent history entry containing `query`, searching back from before
112
+ * `from` (exclusive; history is oldest first). Returns its index, or -1.
113
+ */
114
+ export function searchHistory(history, query, from = history.length) {
115
+ if (!query)
116
+ return -1;
117
+ const needle = query.toLowerCase();
118
+ for (let i = Math.min(from, history.length) - 1; i >= 0; i--) {
119
+ if (history[i].toLowerCase().includes(needle))
120
+ return i;
121
+ }
122
+ return -1;
123
+ }
@@ -0,0 +1,103 @@
1
+ import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { Mode } from "../../core/agentSpec.js";
3
+ import { type ModeControl } from "../../core/session.js";
4
+ import { type ChatTuiOptions } from "../chatTui.js";
5
+ import { type RenderApproval } from "./SessionView.js";
6
+ import type { ToolPhrase } from "./toolGroup.js";
7
+ import { type HeaderInfo } from "./header.js";
8
+ export type { HeaderInfo } from "./header.js";
9
+ export interface InkChatOptions extends ChatTuiOptions {
10
+ /**
11
+ * Title, fields and an optional logo for the top of the chat: fixed at the top in full
12
+ * screen, printed once at the start otherwise. Not written to the session log.
13
+ */
14
+ header?: HeaderInfo;
15
+ /** Replaces the default preview in the approval and manual-intervention panels. */
16
+ renderApproval?: RenderApproval;
17
+ /** Shown in the status bar. */
18
+ mode?: Mode;
19
+ /** Use the plain readline chat (`runChatTui()`) even on a TTY. */
20
+ plain?: boolean;
21
+ /**
22
+ * Take the whole terminal (its alternate screen): the history scrolls in its own view
23
+ * above a prompt pinned at the bottom (PageUp/PageDown and the wheel scroll, Ctrl+End or
24
+ * typing goes back to the bottom), dragging selects text and a right-click copies it,
25
+ * and the terminal is left cleared on exit. Off by default.
26
+ */
27
+ fullscreen?: boolean;
28
+ /**
29
+ * Show the model's predicted next prompt in the empty prompt, accepted with Tab (the SDK's
30
+ * `promptSuggestions`: after every turn but the first, from the parent's prompt cache).
31
+ * On by default; `false` turns it off.
32
+ */
33
+ promptSuggestions?: boolean;
34
+ /**
35
+ * Show the chat's state outside it (on by default): the tab title during a turn, the
36
+ * taskbar button's progress, and a mark on it when a turn ends while the window isn't
37
+ * focused (Windows Terminal). Also enables `/copy`, which copies the last reply.
38
+ */
39
+ terminalIntegration?: boolean;
40
+ /**
41
+ * How one of the agent's own tools counts in a folded group's summary, e.g.
42
+ * `["opened {n} page", "opened {n} pages"]`; built-in tools have their own, and any other
43
+ * counts as "used {n} tools".
44
+ */
45
+ toolPhrase?: (toolName: string) => ToolPhrase | undefined;
46
+ /**
47
+ * The session's mode control (`buildSessionOptions()` returns it): the status bar shows the
48
+ * current mode, and Shift+Tab switches between the modes it allows ("guided" and
49
+ * "interactive"; an autonomous session can't switch).
50
+ */
51
+ modeControl?: ModeControl;
52
+ /**
53
+ * The suggestion shown (and taken with Tab) until the first turn starts: the SDK never
54
+ * suggests after the first turn, so without it the prompt is empty until the second reply.
55
+ */
56
+ firstPromptSuggestion?: string;
57
+ }
58
+ export interface ChatInputSnapshot {
59
+ /** The chat loop is waiting for a line (no turn in flight). */
60
+ prompting: boolean;
61
+ history: string[];
62
+ commands: string[];
63
+ /** Lines submitted during a turn, sent in order once the loop asks for the next one. */
64
+ queued: string[];
65
+ /** The model's predicted next prompt, if any (see `InkChatOptions.promptSuggestions`). */
66
+ suggestion: string | null;
67
+ /** Project files for `@` mentions. */
68
+ files: string[];
69
+ /** The current mode, when a mode control is given. */
70
+ mode: Mode | null;
71
+ closed: boolean;
72
+ }
73
+ /**
74
+ * Hands typed lines from the React prompt to the chat loop. The prompt stays on screen
75
+ * during a turn, as in Claude Code: a line submitted then waits in `queued` and is the
76
+ * next one the loop gets.
77
+ */
78
+ export declare function createChatInput(): {
79
+ subscribe(listener: () => void): () => void;
80
+ getSnapshot: () => ChatInputSnapshot;
81
+ /** Resolves with the next line (a queued one first), or null once the human asked to leave. */
82
+ next(history: string[]): Promise<string | null>;
83
+ submit(line: string | null): void;
84
+ setCommands: (commands: string[]) => void;
85
+ setSuggestion: (suggestion: string | null) => void;
86
+ setFiles: (files: string[]) => void;
87
+ setMode: (mode: Mode | null) => void;
88
+ /** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
89
+ unqueueLast(): string | null;
90
+ close: () => void;
91
+ };
92
+ /**
93
+ * The Ink counterpart of `runChatTui()`, with the same options, session log and history
94
+ * file: history in the scrollback, the reply streaming in place, a spinner with the
95
+ * current action (and a subagent's), approval panels, "/command" completion and a status
96
+ * bar. Checkpoints go through an Ink `InteractionPort` while the chat runs, so no second
97
+ * stdin reader ever competes with Ink's; the response file keeps working as always.
98
+ *
99
+ * With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
100
+ *
101
+ * Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
102
+ */
103
+ export declare function runChatInk(options: Options, tuiOptions?: InkChatOptions): Promise<void>;
@@ -0,0 +1,298 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createWriteStream } from "node:fs";
3
+ import { stdin, stdout } from "node:process";
4
+ import { useSyncExternalStore } from "react";
5
+ import { Box, render, Text, useInput } from "ink";
6
+ import { getInteractionPort, setInteractionPort } from "../../core/interaction.js";
7
+ import { createInputQueue } from "../../core/session.js";
8
+ import { runQuery } from "../../core/runner.js";
9
+ import { capHistory, loadHistory, runChatTui, saveHistory, slashCommandToken } from "../chatTui.js";
10
+ import * as ui from "../ui.js";
11
+ import { createInkInteraction } from "./inkInteraction.js";
12
+ import { PromptInput } from "./PromptInput.js";
13
+ import { createSessionModel, liveWidth } from "./sessionModel.js";
14
+ import { SessionView } from "./SessionView.js";
15
+ import { stripAnsi } from "./lineBuffer.js";
16
+ import { clipboardSequence, enterFullscreen } from "./fullscreen.js";
17
+ import { createCursorController, CursorContext } from "./terminalCursor.js";
18
+ import { createTerminalStatus, focusFromReport } from "./terminalStatus.js";
19
+ import { listProjectFiles } from "./fileMentions.js";
20
+ import { headerLines } from "./header.js";
21
+ const DEFAULT_PROMPT_LABEL = "\n> ";
22
+ const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
23
+ const DEFAULT_HISTORY_LIMIT = 100;
24
+ // The prompt's frame: a border and one column of padding on each side.
25
+ const PROMPT_FRAME_COLUMNS = 4;
26
+ /**
27
+ * Hands typed lines from the React prompt to the chat loop. The prompt stays on screen
28
+ * during a turn, as in Claude Code: a line submitted then waits in `queued` and is the
29
+ * next one the loop gets.
30
+ */
31
+ export function createChatInput() {
32
+ const listeners = new Set();
33
+ let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, closed: false };
34
+ let pending = null;
35
+ let exited = false;
36
+ function update(changes) {
37
+ snapshot = { ...snapshot, ...changes };
38
+ for (const listener of listeners)
39
+ listener();
40
+ }
41
+ return {
42
+ subscribe(listener) {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ },
46
+ getSnapshot: () => snapshot,
47
+ /** Resolves with the next line (a queued one first), or null once the human asked to leave. */
48
+ next(history) {
49
+ if (exited)
50
+ return Promise.resolve(null);
51
+ const [first, ...rest] = snapshot.queued;
52
+ if (first !== undefined) {
53
+ update({ queued: rest, history });
54
+ return Promise.resolve(first);
55
+ }
56
+ update({ prompting: true, history });
57
+ return new Promise((resolve) => (pending = resolve));
58
+ },
59
+ submit(line) {
60
+ if (line === null)
61
+ exited = true;
62
+ const resolve = pending;
63
+ if (!resolve) {
64
+ // A turn is in flight: keep the line for later (an empty one means nothing).
65
+ if (line !== null && line.trim())
66
+ update({ queued: [...snapshot.queued, line] });
67
+ return;
68
+ }
69
+ pending = null;
70
+ update({ prompting: false });
71
+ resolve(line);
72
+ },
73
+ setCommands: (commands) => update({ commands }),
74
+ setSuggestion: (suggestion) => update({ suggestion }),
75
+ setFiles: (files) => update({ files }),
76
+ setMode: (mode) => update({ mode }),
77
+ /** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
78
+ unqueueLast() {
79
+ const last = snapshot.queued.at(-1);
80
+ if (last === undefined)
81
+ return null;
82
+ update({ queued: snapshot.queued.slice(0, -1) });
83
+ return last;
84
+ },
85
+ close: () => update({ prompting: false, closed: true }),
86
+ };
87
+ }
88
+ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl, onInterrupt }) {
89
+ const chat = useSyncExternalStore(input.subscribe, input.getSnapshot);
90
+ const session = useSyncExternalStore(model.subscribe, model.getSnapshot);
91
+ const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
92
+ // Ctrl+C stops what is running (a checkpoint, then the turn) and leaves only when idle;
93
+ // Esc interrupts the turn but never answers a checkpoint or leaves.
94
+ useInput((text, key) => {
95
+ const focused = focusFromReport(text);
96
+ if (focused !== null) {
97
+ onFocusChange?.(focused);
98
+ return;
99
+ }
100
+ if (key.ctrl && text === "o") {
101
+ model.toggleExpanded(); // folds or unfolds the tool groups
102
+ return;
103
+ }
104
+ if (key.shift && key.tab) {
105
+ // Shift+Tab: the next mode this session allows.
106
+ if (!modeControl || modeControl.switchable.length < 2) {
107
+ model.note(ui.dim(`(${modeControl?.mode ?? mode ?? "this"} mode can't be switched during the session)`), "notice");
108
+ return;
109
+ }
110
+ const next = modeControl.switchable[(modeControl.switchable.indexOf(modeControl.mode) + 1) % modeControl.switchable.length];
111
+ modeControl.set(next);
112
+ input.setMode(next);
113
+ return;
114
+ }
115
+ if (key.ctrl && text === "c") {
116
+ if (checkpoint)
117
+ checkpoint.answer(checkpoint.kind === "decision" ? "q" : "");
118
+ if (checkpoint || session.busy)
119
+ onInterrupt();
120
+ else
121
+ input.submit(null);
122
+ }
123
+ else if (key.escape && session.busy && !checkpoint) {
124
+ onInterrupt();
125
+ }
126
+ });
127
+ return (_jsx(SessionView, { model: model, interaction: interaction, renderApproval: renderApproval, mode: chat.mode ?? mode, modeSwitchable: (modeControl?.switchable.length ?? 0) > 1, closed: chat.closed, fullscreen: fullscreen, header: header, onCopy: fullscreen ? (text) => void stdout.write(clipboardSequence(text)) : undefined, children: _jsxs(Box, { flexDirection: "column", children: [chat.queued.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: ui.dim(` queued: ${line}`) }, index))), _jsx(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(PromptInput, { label: promptLabel.replace(/^\n+/, ""), history: chat.history, commands: chat.commands, inset: PROMPT_FRAME_COLUMNS, suggestion: chat.suggestion, onSubmit: (line) => input.submit(line), onRecallQueued: () => input.unqueueLast(), files: chat.files, onExit: () => input.submit(null) }) })] }) }));
128
+ }
129
+ /**
130
+ * The Ink counterpart of `runChatTui()`, with the same options, session log and history
131
+ * file: history in the scrollback, the reply streaming in place, a spinner with the
132
+ * current action (and a subagent's), approval panels, "/command" completion and a status
133
+ * bar. Checkpoints go through an Ink `InteractionPort` while the chat runs, so no second
134
+ * stdin reader ever competes with Ink's; the response file keeps working as always.
135
+ *
136
+ * With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
137
+ *
138
+ * Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
139
+ */
140
+ export async function runChatInk(options, tuiOptions = {}) {
141
+ if (tuiOptions.plain || !stdin.isTTY || !stdout.isTTY)
142
+ return await runChatTui(options, tuiOptions);
143
+ const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
144
+ const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
145
+ const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
146
+ let historyEntries = tuiOptions.historyPath ? capHistory(await loadHistory(tuiOptions.historyPath), historyLimit) : [];
147
+ const queue = createInputQueue();
148
+ const run = runQuery(queue.iterable, { ...options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
149
+ const events = run.events[Symbol.asyncIterator]();
150
+ const sessionLog = tuiOptions.sessionLogPath ? createWriteStream(tuiOptions.sessionLogPath, { flags: "a" }) : null;
151
+ const mirror = (text) => void sessionLog?.write(stripAnsi(text));
152
+ const model = createSessionModel({
153
+ formatAction: tuiOptions.formatAction,
154
+ toolPhrase: tuiOptions.toolPhrase,
155
+ agentLabel: tuiOptions.agentLabel,
156
+ onWrite: mirror,
157
+ width: () => liveWidth(stdout.columns),
158
+ });
159
+ const interaction = createInkInteraction((text) => model.note(text));
160
+ const input = createChatInput();
161
+ if (tuiOptions.firstPromptSuggestion)
162
+ input.setSuggestion(tuiOptions.firstPromptSuggestion);
163
+ if (tuiOptions.modeControl)
164
+ input.setMode(tuiOptions.modeControl.mode);
165
+ void listProjectFiles(options.cwd ?? process.cwd()).then((files) => input.setFiles(files));
166
+ // Every registered command name and alias, built-ins included, for completion and for
167
+ // catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
168
+ const knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
169
+ void knownCommands.then((names) => {
170
+ const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
171
+ const local = tuiOptions.terminalIntegration === false ? [] : ["copy"];
172
+ input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
173
+ });
174
+ let turnInterrupted = false;
175
+ function interruptTurn() {
176
+ if (turnInterrupted)
177
+ return;
178
+ turnInterrupted = true;
179
+ void run.interrupt();
180
+ model.writeLine(ui.warn("(interrupted)"));
181
+ }
182
+ // One reader for the whole session instead of drainTurn()'s one per turn: the prompt
183
+ // suggestion arrives after its turn's turn-end, while the human is already at the prompt,
184
+ // and MCP errors arrive before the first turn. Still manual .next() calls, never a
185
+ // for-await that could be broken out of (ADR-010).
186
+ let turnDone = null;
187
+ let readError = null;
188
+ function finishTurn() {
189
+ const done = turnDone;
190
+ turnDone = null;
191
+ done?.();
192
+ }
193
+ void (async () => {
194
+ try {
195
+ while (true) {
196
+ const { value, done } = await events.next();
197
+ if (done)
198
+ break;
199
+ if (value.type === "prompt-suggestion") {
200
+ input.setSuggestion(value.suggestion);
201
+ continue;
202
+ }
203
+ model.render(value);
204
+ if (value.type === "turn-end")
205
+ finishTurn();
206
+ }
207
+ }
208
+ catch (error) {
209
+ readError = error;
210
+ }
211
+ finishTurn();
212
+ })();
213
+ async function runTurn(line) {
214
+ input.setSuggestion(null);
215
+ status?.turnStarted();
216
+ turnInterrupted = false;
217
+ model.separate();
218
+ model.startTurn();
219
+ const finished = new Promise((resolve) => (turnDone = resolve));
220
+ queue.push(line);
221
+ await finished;
222
+ model.endTurn();
223
+ status?.turnEnded();
224
+ void run.contextUsage().then((usage) => usage && model.setContextPercent(usage.percentage));
225
+ if (readError)
226
+ throw readError;
227
+ }
228
+ const previousPort = getInteractionPort();
229
+ setInteractionPort(interaction.port);
230
+ const restoreTerminal = tuiOptions.fullscreen ? enterFullscreen(stdout) : null;
231
+ const cursor = tuiOptions.fullscreen ? createCursorController(stdout) : null;
232
+ const status = tuiOptions.terminalIntegration === false ? null : createTerminalStatus((text) => void stdout.write(text), tuiOptions.header?.title ?? "agent");
233
+ status?.start();
234
+ const app = render(_jsx(CursorContext.Provider, { value: cursor, children: _jsx(ChatApp, { model: model, interaction: interaction, input: input, promptLabel: promptLabel, renderApproval: tuiOptions.renderApproval, mode: tuiOptions.mode, fullscreen: tuiOptions.fullscreen, header: tuiOptions.header && tuiOptions.fullscreen ? headerLines(tuiOptions.header) : undefined, onFocusChange: status ? (focused) => status.setFocused(focused) : undefined, modeControl: tuiOptions.modeControl, onInterrupt: interruptTurn }) }),
235
+ // In full screen Ink writes through the cursor controller, which places the terminal's cursor.
236
+ { exitOnCtrlC: false, ...(cursor ? { stdout: cursor.stream } : {}) });
237
+ try {
238
+ // In full screen the header is pinned above the history (see SessionView) instead.
239
+ if (tuiOptions.header && !tuiOptions.fullscreen)
240
+ model.note(headerLines(tuiOptions.header).join("\n"));
241
+ if (tuiOptions.welcomeMessage)
242
+ model.writeLine(tuiOptions.welcomeMessage);
243
+ if (tuiOptions.initialPrompt) {
244
+ // Logged but not shown, like runChatTui(): it is the agent's cue, not something typed.
245
+ mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
246
+ await runTurn(tuiOptions.initialPrompt);
247
+ }
248
+ while (true) {
249
+ // The blank line before the prompt is in place while typing, so Enter doesn't push
250
+ // everything down a line: promptLabel's own leading newlines only reach the log.
251
+ model.separate();
252
+ const raw = await input.next(historyEntries.map((entry) => entry.text));
253
+ if (raw === null)
254
+ break;
255
+ const line = raw.trim();
256
+ if (!line)
257
+ continue;
258
+ mirror(`${promptLabel}${line}\n`);
259
+ model.note(`${promptLabel.replace(/^\n+/, "")}${line}`, "user");
260
+ if (tuiOptions.historyPath) {
261
+ historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
262
+ await saveHistory(tuiOptions.historyPath, historyEntries);
263
+ }
264
+ if (exitCommands.has(line.toLowerCase()))
265
+ break;
266
+ // A local command, never sent to the model: the last reply to the clipboard (OSC 52).
267
+ if (status && line.toLowerCase() === "/copy") {
268
+ const reply = model.lastReply();
269
+ if (reply)
270
+ stdout.write(clipboardSequence(reply));
271
+ model.writeLine(ui.dim(reply ? `(copied the last reply: ${reply.length} characters)` : "(nothing to copy yet)"));
272
+ continue;
273
+ }
274
+ const commandToken = slashCommandToken(line);
275
+ if (commandToken) {
276
+ const names = await knownCommands;
277
+ if (names && !names.has(commandToken)) {
278
+ model.writeLine(ui.warn(`Unknown command: /${commandToken}`));
279
+ continue;
280
+ }
281
+ }
282
+ await runTurn(line);
283
+ }
284
+ }
285
+ finally {
286
+ setInteractionPort(previousPort);
287
+ queue.end();
288
+ run.close();
289
+ input.close();
290
+ // One more frame with only the history, so the prompt and status bar don't stay behind.
291
+ await new Promise((resolve) => setTimeout(resolve, 0));
292
+ app.unmount();
293
+ await app.waitUntilExit();
294
+ status?.stop();
295
+ restoreTerminal?.();
296
+ sessionLog?.end();
297
+ }
298
+ }