@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,333 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext, useLayoutEffect, useReducer, useRef } from "react";
3
+ import { Box, Text, useInput, useStdout } from "ink";
4
+ import stringWidth from "string-width";
5
+ import { fitWidth, stripAnsi } from "./lineBuffer.js";
6
+ import { liveWidth } from "./sessionModel.js";
7
+ import { isMouseReport } from "./fullscreen.js";
8
+ import { CursorContext } from "./terminalCursor.js";
9
+ import { isFocusReport } from "./terminalStatus.js";
10
+ import { matchingFiles, mentionAt } from "./fileMentions.js";
11
+ import { continueLine, createPasteRegistry, cursorLine, deleteBack, deleteWordBack, insert, isBlockPaste, killToLineEnd, moveLine, normalizePaste, searchHistory, toLineEnd, toLineStart, wordLeft, wordRight, } from "./promptEditing.js";
12
+ import * as ui from "../ui.js";
13
+ const MAX_SUGGESTIONS = 6;
14
+ /** Commands (names without "/") a partial "/name" line could still become. */
15
+ export function matchingCommands(value, commands) {
16
+ if (!value.startsWith("/") || /\s/.test(value))
17
+ return [];
18
+ const typed = value.slice(1);
19
+ return commands.filter((name) => name.startsWith(typed));
20
+ }
21
+ /** Tab: completes a unique match (plus a space for its arguments), else the common prefix. */
22
+ export function completeCommand(value, commands) {
23
+ const matches = matchingCommands(value, commands);
24
+ if (matches.length === 0)
25
+ return value;
26
+ if (matches.length === 1)
27
+ return `/${matches[0]} `;
28
+ let prefix = matches[0];
29
+ for (const name of matches) {
30
+ while (!name.startsWith(prefix))
31
+ prefix = prefix.slice(0, -1);
32
+ }
33
+ return `/${prefix}`;
34
+ }
35
+ /**
36
+ * The slice [start, end) of a `length`-character line shown in `room` columns, keeping the
37
+ * cursor in view: like the live area, the prompt must never reach the terminal's edge (see
38
+ * lineBuffer.ts), so a long line scrolls sideways, with "…" on each cut side.
39
+ */
40
+ export function visibleWindow(length, cursor, room) {
41
+ if (length + 1 <= room)
42
+ return [0, length];
43
+ const span = Math.max(1, room - 2); // one column for each possible "…"
44
+ const start = Math.min(Math.max(0, cursor + 1 - span), Math.max(0, length + 1 - span));
45
+ return [start, Math.min(length, start + span)];
46
+ }
47
+ /** The shortcuts panel, opened with "?" on an empty prompt. */
48
+ export const SHORTCUTS = [
49
+ "Enter send · \\ + Enter or Ctrl+J new line · Tab complete or take the suggestion",
50
+ "↑/↓ history · Ctrl+R search history · ↑ on an empty prompt edits the last queued message",
51
+ "Ctrl+W delete word · Ctrl+K to line end · Ctrl+U clear · Ctrl+←/→ move by word",
52
+ "@ mention a file · /copy copy the last reply · Shift+Tab switch mode · Ctrl+O unfold tool calls",
53
+ "Esc interrupt · Ctrl+C interrupt or exit · PgUp/PgDn or wheel scroll · Ctrl+End bottom",
54
+ "Drag to select, right-click to copy (full screen)",
55
+ ];
56
+ // A paste can reach us in several chunks; pieces this close together extend the same one.
57
+ const PASTE_CHUNK_MS = 100;
58
+ const PASTE_TOKEN_BEFORE = /\[Pasted text #\d+ \+\d+ lines?\]$/;
59
+ /**
60
+ * The chat's prompt: history (↑/↓), "/command" completion and suggestions (Tab), pasted
61
+ * blocks folded into a token, multi-line input (`\` + Enter or Ctrl+J), word and line
62
+ * shortcuts (Ctrl+W/K/U, Ctrl+←/→, Home/End, Ctrl+A/E), reverse history search (Ctrl+R)
63
+ * and taking a queued line back (↑ on an empty prompt). Written here rather than taken from
64
+ * @inkjs/ui because its TextInput is uncontrolled. Backspace also arrives as `delete` on
65
+ * Windows terminals, so both erase backwards.
66
+ */
67
+ export function PromptInput({ label, inset = 0, suggestion, history, commands, onSubmit, onExit, onRecallQueued, files = [] }) {
68
+ // Kept in a ref, not in state: two keystrokes can arrive before React re-renders, and a
69
+ // handler reading state would apply the second one to a stale value.
70
+ const state = useRef({
71
+ value: "",
72
+ cursor: 0,
73
+ historyIndex: null,
74
+ draft: "",
75
+ search: null,
76
+ lastPaste: null,
77
+ showHelp: false,
78
+ });
79
+ const pastes = useRef(createPasteRegistry()).current;
80
+ const [, rerender] = useReducer((n) => n + 1, 0);
81
+ function apply(next) {
82
+ state.current.value = next.value;
83
+ state.current.cursor = next.cursor;
84
+ }
85
+ function replace(next) {
86
+ apply({ value: next, cursor: next.length });
87
+ }
88
+ /** A key while Ctrl+R's search is on: Ctrl+R finds an older match, Enter (or any other key) takes it, Esc cancels. */
89
+ function handleSearch(search, input, key) {
90
+ const current = state.current;
91
+ const finish = (take) => {
92
+ current.search = null;
93
+ if (take && search.index >= 0)
94
+ replace(history[search.index]);
95
+ else
96
+ apply(search.saved);
97
+ };
98
+ if (key.ctrl && input === "r") {
99
+ const older = searchHistory(history, search.query, search.index >= 0 ? search.index : history.length);
100
+ if (older >= 0)
101
+ search.index = older;
102
+ }
103
+ else if (key.escape) {
104
+ finish(false);
105
+ }
106
+ else if (key.return) {
107
+ finish(true);
108
+ }
109
+ else if (key.backspace || key.delete) {
110
+ search.query = search.query.slice(0, -1);
111
+ search.index = searchHistory(history, search.query);
112
+ }
113
+ else if (input && !key.ctrl && !key.meta) {
114
+ search.query += input;
115
+ search.index = searchHistory(history, search.query);
116
+ }
117
+ else {
118
+ finish(true);
119
+ }
120
+ }
121
+ function handlePaste(input) {
122
+ const current = state.current;
123
+ const text = normalizePaste(input);
124
+ const now = Date.now();
125
+ const last = current.lastPaste;
126
+ const before = current.value.slice(0, current.cursor);
127
+ // Typing sends one character at a time: only a multi-character chunk can be more of a paste.
128
+ if (last && input.length > 1 && now - last.at < PASTE_CHUNK_MS && before.endsWith(last.token)) {
129
+ // The rest of the same paste: grow its token in place.
130
+ const token = pastes.extend(last.token, text);
131
+ const start = current.cursor - last.token.length;
132
+ apply({ value: current.value.slice(0, start) + token + current.value.slice(current.cursor), cursor: start + token.length });
133
+ current.lastPaste = { token, at: now };
134
+ }
135
+ else if (isBlockPaste(text)) {
136
+ const token = pastes.add(text);
137
+ apply(insert(current, token));
138
+ current.lastPaste = { token, at: now };
139
+ }
140
+ else {
141
+ apply(insert(current, text));
142
+ }
143
+ }
144
+ useInput((input, key) => {
145
+ // In full screen the mouse is reported as input (wheel, selection): never type it.
146
+ // Nor the terminal's focus reports (see terminalStatus.ts).
147
+ if (isMouseReport(input) || isFocusReport(input))
148
+ return;
149
+ const current = state.current;
150
+ if (current.showHelp) {
151
+ current.showHelp = false; // any key closes the shortcuts panel
152
+ rerender();
153
+ return;
154
+ }
155
+ if (current.search) {
156
+ handleSearch(current.search, input, key);
157
+ rerender();
158
+ return;
159
+ }
160
+ const { value, cursor } = current;
161
+ if (key.return) {
162
+ const continued = continueLine(current);
163
+ if (continued) {
164
+ apply(continued);
165
+ rerender();
166
+ return;
167
+ }
168
+ replace("");
169
+ current.historyIndex = null;
170
+ rerender();
171
+ onSubmit(pastes.expand(value));
172
+ return;
173
+ }
174
+ if (input === "\n" && !key.ctrl) {
175
+ apply(insert(current, "\n")); // Ctrl+J: a line break without sending
176
+ }
177
+ else if (key.ctrl && input === "d") {
178
+ if (value === "")
179
+ onExit();
180
+ return;
181
+ }
182
+ else if (key.ctrl && input === "u") {
183
+ replace(""); // clears the whole prompt
184
+ current.historyIndex = null;
185
+ }
186
+ else if (key.ctrl && input === "w") {
187
+ apply(deleteWordBack(current));
188
+ }
189
+ else if (key.ctrl && input === "k") {
190
+ apply(killToLineEnd(current));
191
+ }
192
+ else if (key.ctrl && input === "r") {
193
+ current.search = { query: "", index: -1, saved: { value, cursor } };
194
+ }
195
+ else if (key.upArrow) {
196
+ const moved = moveLine(current, -1);
197
+ if (moved) {
198
+ apply(moved);
199
+ }
200
+ else if (value === "" && onRecallQueued) {
201
+ const queued = onRecallQueued();
202
+ if (queued !== null)
203
+ replace(queued);
204
+ else
205
+ return upHistory();
206
+ }
207
+ else {
208
+ return upHistory();
209
+ }
210
+ }
211
+ else if (key.downArrow) {
212
+ const moved = moveLine(current, 1);
213
+ if (moved)
214
+ apply(moved);
215
+ else
216
+ return downHistory();
217
+ }
218
+ else if (key.tab) {
219
+ if (key.shift)
220
+ return; // Shift+Tab switches the mode (see runChatInk.tsx)
221
+ // An @mention completes to a file; an empty prompt takes the suggestion; otherwise
222
+ // Tab completes a "/command".
223
+ const mention = mentionAt(value, cursor);
224
+ const file = mention ? matchingFiles(files, mention.query, 1)[0] : undefined;
225
+ if (mention && file) {
226
+ const completed = `@${file} `;
227
+ apply({ value: value.slice(0, mention.start) + completed + value.slice(cursor), cursor: mention.start + completed.length });
228
+ }
229
+ else {
230
+ replace(value === "" && suggestion ? suggestion : completeCommand(value, commands));
231
+ }
232
+ }
233
+ else if (key.leftArrow) {
234
+ apply(key.ctrl ? wordLeft(current) : { value, cursor: Math.max(0, cursor - 1) });
235
+ }
236
+ else if (key.rightArrow) {
237
+ apply(key.ctrl ? wordRight(current) : { value, cursor: Math.min(value.length, cursor + 1) });
238
+ }
239
+ else if (key.home || (key.ctrl && input === "a")) {
240
+ apply(toLineStart(current));
241
+ }
242
+ else if (key.end || (key.ctrl && input === "e")) {
243
+ apply(toLineEnd(current));
244
+ }
245
+ else if (key.backspace || key.delete) {
246
+ // A pasted block goes away whole.
247
+ const token = PASTE_TOKEN_BEFORE.exec(value.slice(0, cursor))?.[0];
248
+ apply(token ? { value: value.slice(0, cursor - token.length) + value.slice(cursor), cursor: cursor - token.length } : deleteBack(current));
249
+ }
250
+ else if (key.ctrl || key.meta || key.escape || !input) {
251
+ return;
252
+ }
253
+ else if (value === "" && input === "?") {
254
+ current.showHelp = true;
255
+ }
256
+ else {
257
+ handlePaste(input);
258
+ }
259
+ rerender();
260
+ });
261
+ function upHistory() {
262
+ const current = state.current;
263
+ if (history.length === 0)
264
+ return;
265
+ if (current.historyIndex === null)
266
+ current.draft = current.value;
267
+ current.historyIndex = current.historyIndex === null ? history.length - 1 : Math.max(0, current.historyIndex - 1);
268
+ replace(history[current.historyIndex]);
269
+ rerender();
270
+ }
271
+ function downHistory() {
272
+ const current = state.current;
273
+ if (current.historyIndex === null)
274
+ return;
275
+ current.historyIndex++;
276
+ if (current.historyIndex < history.length) {
277
+ replace(history[current.historyIndex]);
278
+ }
279
+ else {
280
+ current.historyIndex = null;
281
+ replace(current.draft);
282
+ }
283
+ rerender();
284
+ }
285
+ const { value, cursor, search, showHelp } = state.current;
286
+ const { stdout } = useStdout();
287
+ const width = liveWidth(stdout.columns) - inset;
288
+ const labelWidth = stringWidth(stripAnsi(label));
289
+ const room = width - labelWidth;
290
+ const ghost = suggestion ? `${suggestion} (tab)` : null;
291
+ // Suggestions give way on a short terminal: the live area must stay shorter than it.
292
+ const maxSuggestions = Math.min(MAX_SUGGESTIONS, Math.max(0, (stdout.rows || 24) - 10));
293
+ const mention = mentionAt(value, cursor);
294
+ const suggestions = mention
295
+ ? matchingFiles(files, mention.query, maxSuggestions).map((file) => `@${file}`)
296
+ : value.includes("\n")
297
+ ? []
298
+ : matchingCommands(value, commands).slice(0, maxSuggestions).map((name) => `/${name}`);
299
+ // With a cursor controller (full screen) the terminal's own cursor sits in the line;
300
+ // without one, the character under the cursor is drawn inverted.
301
+ const cursorController = useContext(CursorContext);
302
+ const cursorLineRef = useRef(null);
303
+ let rows;
304
+ let cursorColumn;
305
+ if (search) {
306
+ const prefix = `(reverse-i-search)'${search.query}': `;
307
+ const match = search.index >= 0 ? history[search.index] : "";
308
+ cursorColumn = Math.min(stringWidth(prefix) - 3, width - 1);
309
+ rows = [
310
+ _jsx(Box, { ref: cursorLineRef, children: _jsx(Text, { children: fitWidth(`${ui.dim(prefix)}${match.replace(/\n/g, " ")}`, width) }) }, "search"),
311
+ ];
312
+ }
313
+ else {
314
+ const lines = value.split("\n");
315
+ const at = cursorLine({ value, cursor });
316
+ cursorColumn = 0;
317
+ rows = lines.map((line, i) => {
318
+ const prefix = i === 0 ? label : " ".repeat(labelWidth);
319
+ const isCursorLine = i === at.line;
320
+ const [start, end] = visibleWindow(line.length, isCursorLine ? at.column : 0, room);
321
+ if (isCursorLine)
322
+ cursorColumn = labelWidth + (start > 0 ? 1 : 0) + stringWidth(line.slice(start, at.column));
323
+ const col = at.column;
324
+ return (_jsx(Box, { ref: isCursorLine ? cursorLineRef : undefined, children: _jsxs(Text, { children: [prefix, start > 0 ? "…" : "", isCursorLine ? line.slice(start, col) : line.slice(start, end), isCursorLine ? (cursorController ? line.slice(col, col + 1) : _jsx(Text, { inverse: true, children: line[col] ?? " " })) : "", isCursorLine ? line.slice(col + 1, end) : "", end < line.length ? "…" : "", value === "" && ghost ? ui.dim(fitWidth(ghost, Math.max(1, room - 1))) : ""] }) }, i));
325
+ });
326
+ }
327
+ useLayoutEffect(() => {
328
+ if (cursorController && cursorLineRef.current)
329
+ cursorController.setTarget({ node: cursorLineRef.current, column: cursorColumn });
330
+ });
331
+ useLayoutEffect(() => () => cursorController?.setTarget(null), [cursorController]);
332
+ return (_jsxs(Box, { flexDirection: "column", children: [rows, suggestions.map((item) => (_jsx(Text, { children: ui.dim(fitWidth(` ${item}`, width)) }, item))), showHelp ? SHORTCUTS.map((line) => _jsx(Text, { children: ui.dim(fitWidth(` ${line}`, width)) }, line)) : null] }));
333
+ }
@@ -0,0 +1,42 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ApprovalPrompt } from "../../core/interaction.js";
3
+ import type { Mode } from "../../core/agentSpec.js";
4
+ import type { SessionUsage } from "../../core/runner.js";
5
+ import { type SessionModel } from "./sessionModel.js";
6
+ import type { InkInteraction } from "./inkInteraction.js";
7
+ /** Replaces the default preview (title and lines) of an approval panel; the choices stay. */
8
+ export type RenderApproval = (prompt: ApprovalPrompt) => ReactNode;
9
+ /**
10
+ * The preview's lines, capped so the whole live area stays shorter than the terminal: Ink
11
+ * clears the entire screen (scrollback included) to redraw anything taller, and a step-gate
12
+ * preview carries the tool's parameters, which can be a whole file.
13
+ */
14
+ export declare function previewLines(lines: readonly string[], terminalRows: number | undefined): string[];
15
+ export declare function statusText(mode: Mode | undefined, turns: number, usage: SessionUsage | null, extra?: {
16
+ contextPercent?: number | null;
17
+ modeSwitchable?: boolean;
18
+ }): string;
19
+ export interface SessionViewProps {
20
+ model: SessionModel;
21
+ interaction: InkInteraction;
22
+ renderApproval?: RenderApproval;
23
+ mode?: Mode;
24
+ /** Leaves only the history, so the last frame Ink leaves on screen is just the scrollback. */
25
+ closed?: boolean;
26
+ /**
27
+ * Draws the whole terminal: the history in a scrollable view above the live area, instead
28
+ * of in the terminal's own scrollback. The caller switches the terminal to the alternate
29
+ * screen (see fullscreen.ts's enterFullscreen()).
30
+ */
31
+ fullscreen?: boolean;
32
+ /** Full screen only: lines pinned above the history (see header.ts's headerLines()). */
33
+ header?: string[];
34
+ /** Full screen only: receives the text selected with the mouse (e.g. to set the clipboard). */
35
+ onCopy?: (text: string) => void;
36
+ /** The mode can be switched (Shift+Tab): the status bar says so. */
37
+ modeSwitchable?: boolean;
38
+ /** Shown under the live area when no checkpoint is waiting (the chat's input). */
39
+ children?: ReactNode;
40
+ }
41
+ /** History, line in progress, current action, checkpoint panel and status bar. */
42
+ export declare function SessionView({ model, interaction, renderApproval, mode, closed, fullscreen, header, onCopy, modeSwitchable, children }: SessionViewProps): import("react").JSX.Element | null;
@@ -0,0 +1,223 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Box, measureElement, Static, Text, useInput, useStdout } from "ink";
4
+ import { Select } from "@inkjs/ui";
5
+ import { liveWidth } from "./sessionModel.js";
6
+ import { createRowCache, highlightRow, isMouseReport, mouseEvents, rowsBelow, scrollBy, selectedText, visibleRows, userBarRows, } from "./fullscreen.js";
7
+ import { framePosition } from "./terminalCursor.js";
8
+ import { isFocusReport } from "./terminalStatus.js";
9
+ import { fitWidth, stripAnsi } from "./lineBuffer.js";
10
+ import * as ui from "../ui.js";
11
+ // Numbered as in the Claude Code CLI's permission prompt; the digits and y/n/q answer too.
12
+ const DECISION_OPTIONS = [
13
+ { label: "1. Yes", value: "y" },
14
+ { label: "2. No", value: "n" },
15
+ { label: "3. Stop", value: "q" },
16
+ ];
17
+ const MANUAL_OPTIONS = [{ label: "1. Done, continue", value: "continue" }];
18
+ const DECISION_KEYS = { "1": "y", "2": "n", "3": "q", y: "y", n: "n", q: "q" };
19
+ const ACCENT_HEX = "#d77757";
20
+ /**
21
+ * The preview's lines, capped so the whole live area stays shorter than the terminal: Ink
22
+ * clears the entire screen (scrollback included) to redraw anything taller, and a step-gate
23
+ * preview carries the tool's parameters, which can be a whole file.
24
+ */
25
+ export function previewLines(lines, terminalRows) {
26
+ const all = lines.flatMap((line) => line.split("\n"));
27
+ const max = Math.max(3, (terminalRows || 24) - 12);
28
+ return all.length <= max ? all : [...all.slice(0, max - 1), ui.dim(`… (${all.length - max + 1} more lines)`)];
29
+ }
30
+ function CheckpointPanel({ checkpoint, renderApproval }) {
31
+ const decision = checkpoint.kind === "decision";
32
+ useInput((input) => {
33
+ const key = input.toLowerCase();
34
+ if (decision && DECISION_KEYS[key])
35
+ checkpoint.answer(DECISION_KEYS[key]);
36
+ else if (!decision && key === "1")
37
+ checkpoint.answer("");
38
+ });
39
+ const { prompt } = checkpoint;
40
+ const { stdout } = useStdout();
41
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: ACCENT_HEX, paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Do you want to proceed?" }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? DECISION_OPTIONS : MANUAL_OPTIONS, onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
42
+ }
43
+ function formatTokens(count) {
44
+ return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
45
+ }
46
+ export function statusText(mode, turns, usage, extra = {}) {
47
+ const parts = [
48
+ ...(mode ? [`⏵⏵ ${mode}${extra.modeSwitchable ? " (shift+tab)" : ""}`] : []),
49
+ `${turns} ${turns === 1 ? "turn" : "turns"}`,
50
+ ...(usage ? [`${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out`] : []),
51
+ ...(extra.contextPercent != null ? [`context ${Math.round(extra.contextPercent)}%`] : []),
52
+ ];
53
+ return parts.join(" · ");
54
+ }
55
+ // No emoji-capable characters: Windows Terminal draws ✳ (U+2733) as a green emoji.
56
+ const WORKING_GLYPHS = ["·", "✢", "✱", "✶", "✻", "✽", "✻", "✶", "✱", "✢"];
57
+ /** The spinner line, as in the Claude Code CLI: an animated glyph, what it's doing, how long it's been at it. */
58
+ function Working({ label, startedAt, width }) {
59
+ const [tick, setTick] = useState(0);
60
+ useEffect(() => {
61
+ const timer = setInterval(() => setTick((n) => n + 1), 120);
62
+ return () => clearInterval(timer);
63
+ }, []);
64
+ const seconds = startedAt === null ? 0 : Math.floor((Date.now() - startedAt) / 1000);
65
+ const glyph = WORKING_GLYPHS[tick % WORKING_GLYPHS.length];
66
+ return _jsx(Text, { children: fitWidth(`${ui.working(`${glyph} ${label}`)} ${ui.dim(`(${seconds}s · esc to interrupt)`)}`, width) });
67
+ }
68
+ /** Line in progress, checkpoint panel or spinner, the input, and the status bar. */
69
+ function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtra, modeSwitchable, children }) {
70
+ // The mode in the spinner's color; the rest dim.
71
+ const plain = statusText(mode, session.turns, session.usage, { contextPercent: session.contextPercent, modeSwitchable }) + (statusExtra ? ` · ${statusExtra}` : "");
72
+ const cut = mode ? plain.indexOf(" · ") : -1;
73
+ const status = cut > 0 ? ui.working(plain.slice(0, cut)) + ui.dim(plain.slice(cut)) : ui.dim(plain);
74
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, marginTop: session.liveGap ? 1 : 0, children: [stripAnsi(session.live) ? _jsx(Text, { children: session.live }) : null, checkpoint ? (_jsx(CheckpointPanel, { checkpoint: checkpoint, renderApproval: renderApproval })) : session.busy ? (
75
+ // Always one blank line above the spinner: from this margin, or from the live area's
76
+ // own when a separator is pending and nothing is written above the spinner yet.
77
+ _jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? "Thinking…", startedAt: session.turnStartedAt, width: width }), session.subagentActivity ? _jsx(Text, { dimColor: true, children: fitWidth(` ↳ ${session.subagentActivity}`, width) }) : null] })) : null, checkpoint ? null : children, _jsx(Text, { children: fitWidth(status, width) })] }));
78
+ }
79
+ /** A history line for the inline view: the human's line as a bar, a tool group unfolded with Ctrl+O. */
80
+ function inlineItem(item, expanded, width) {
81
+ if (item.kind === "user")
82
+ return userBarRows(item.text, width).join("\n");
83
+ if (expanded && item.expanded)
84
+ return item.expanded.join("\n");
85
+ return item.text || " ";
86
+ }
87
+ /** The terminal's size, re-read on resize (Ink re-lays out on resize but doesn't re-render components). */
88
+ function useTerminalSize() {
89
+ const { stdout } = useStdout();
90
+ const [, rerender] = useReducer((n) => n + 1, 0);
91
+ useEffect(() => {
92
+ stdout.on("resize", rerender);
93
+ return () => void stdout.off("resize", rerender);
94
+ }, [stdout]);
95
+ return { columns: stdout.columns || 80, rows: stdout.rows || 24 };
96
+ }
97
+ const WHEEL_ROWS = 3;
98
+ /**
99
+ * The full-screen layout: a frame exactly as tall as the terminal (so Ink redraws it whole
100
+ * from the top every time, never relative to where it thinks the cursor is — a shorter frame
101
+ * left stale copies behind on Windows Terminal) and one column narrower. The history fills
102
+ * what the live area leaves, bottom-aligned, and scrolls on its own: PageUp/PageDown and the
103
+ * wheel move it, Ctrl+End or typing brings it back to the bottom, and output arriving while
104
+ * scrolled up doesn't move it.
105
+ */
106
+ function FullscreenSession({ session, checkpoint, renderApproval, mode, header, onCopy, modeSwitchable, children, }) {
107
+ const { columns, rows } = useTerminalSize();
108
+ const width = liveWidth(columns);
109
+ const rowCache = useRef(createRowCache()).current;
110
+ const wrapped = rowCache(session.items, width, session.expanded);
111
+ const total = wrapped.rows.length;
112
+ const historyRef = useRef(null);
113
+ const [historyHeight, setHistoryHeight] = useState(rows);
114
+ useEffect(() => {
115
+ if (!historyRef.current)
116
+ return;
117
+ const { height } = measureElement(historyRef.current);
118
+ if (height !== historyHeight)
119
+ setHistoryHeight(height);
120
+ });
121
+ const [anchor, setAnchor] = useState(null);
122
+ // Mirrored in a ref: mouse reports can arrive faster than React re-renders.
123
+ const [selection, setSelectionState] = useState(null);
124
+ const selectionRef = useRef(null);
125
+ function setSelection(next) {
126
+ selectionRef.current = next;
127
+ setSelectionState(next);
128
+ }
129
+ const dragging = useRef(false);
130
+ const [copied, setCopied] = useState(null);
131
+ // Rows are re-wrapped when the width changes, so a row index no longer means the same
132
+ // place: go back to the bottom, and drop the selection.
133
+ useEffect(() => {
134
+ setAnchor(null);
135
+ setSelection(null);
136
+ }, [width]);
137
+ useEffect(() => {
138
+ if (!copied)
139
+ return;
140
+ const timer = setTimeout(() => setCopied(null), 3000);
141
+ return () => clearTimeout(timer);
142
+ }, [copied]);
143
+ const [start, end] = visibleRows(total, historyHeight, anchor);
144
+ /** The history cell under a screen cell (clamped to the rows shown), or null outside the history. */
145
+ function cellAt(x, y, clamp) {
146
+ if (!historyRef.current || end === start)
147
+ return null;
148
+ const box = framePosition(historyRef.current);
149
+ const firstY = box.y + historyHeight - (end - start); // the rows are bottom-aligned
150
+ let offset = y - firstY;
151
+ if (offset < 0 || offset >= end - start) {
152
+ if (!clamp)
153
+ return null;
154
+ offset = Math.min(end - start - 1, Math.max(0, offset));
155
+ }
156
+ return { row: start + offset, col: Math.max(0, x - box.x) };
157
+ }
158
+ function handleMouse(text) {
159
+ for (const event of mouseEvents(text)) {
160
+ if (event.kind === "wheel-up" || event.kind === "wheel-down") {
161
+ const steps = event.kind === "wheel-up" ? -WHEEL_ROWS : WHEEL_ROWS;
162
+ setAnchor((a) => scrollBy(a, steps, total, historyHeight));
163
+ }
164
+ else if (event.kind === "press") {
165
+ const cell = cellAt(event.x, event.y, false);
166
+ dragging.current = cell !== null;
167
+ setSelection(cell ? { anchor: cell, focus: cell } : null);
168
+ }
169
+ else if (event.kind === "drag" && dragging.current && selectionRef.current) {
170
+ const cell = cellAt(event.x, event.y, true);
171
+ if (cell)
172
+ setSelection({ anchor: selectionRef.current.anchor, focus: cell });
173
+ }
174
+ else if (event.kind === "release" && dragging.current && selectionRef.current) {
175
+ dragging.current = false;
176
+ const cell = cellAt(event.x, event.y, true);
177
+ const final = cell ? { anchor: selectionRef.current.anchor, focus: cell } : selectionRef.current;
178
+ // A click without a drag clears the selection; a drag leaves it selected.
179
+ setSelection(final.anchor.row === final.focus.row && final.anchor.col === final.focus.col ? null : final);
180
+ }
181
+ else if (event.kind === "right" && selectionRef.current) {
182
+ // As in Windows Terminal: a right-click copies the selection and clears it.
183
+ const text = selectedText(wrapped, selectionRef.current);
184
+ setSelection(null);
185
+ if (text && onCopy) {
186
+ onCopy(text);
187
+ setCopied(`copied ${text.length} ${text.length === 1 ? "character" : "characters"}`);
188
+ }
189
+ }
190
+ }
191
+ }
192
+ useInput((text, key) => {
193
+ const page = Math.max(1, historyHeight - 1);
194
+ if (isMouseReport(text))
195
+ handleMouse(text);
196
+ else if (isFocusReport(text))
197
+ return;
198
+ else if (key.pageUp)
199
+ setAnchor((a) => scrollBy(a, -page, total, historyHeight));
200
+ else if (key.pageDown)
201
+ setAnchor((a) => scrollBy(a, page, total, historyHeight));
202
+ else if (key.ctrl && key.end)
203
+ setAnchor(null);
204
+ else if (text && !key.ctrl && !key.meta && !key.escape)
205
+ setAnchor(null); // typing
206
+ });
207
+ const below = rowsBelow(anchor, total);
208
+ const statusExtra = [anchor === null ? null : `↓ ${below} more ${below === 1 ? "line" : "lines"} (Ctrl+End)`, copied]
209
+ .filter(Boolean)
210
+ .join(" · ");
211
+ return (_jsxs(Box, { flexDirection: "column", width: columns - 1, height: rows, children: [header && header.length > 0 ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, children: header.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line || " " }, i))) })) : null, _jsx(Box, { ref: historyRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", justifyContent: "flex-end", children: wrapped.rows.slice(start, end).map((row, i) => (_jsx(Text, { wrap: "truncate-end", children: highlightRow(row, selection, start + i) || " " }, start + i))) }), _jsx(LiveArea, { session: session, checkpoint: checkpoint, renderApproval: renderApproval, mode: mode, width: width, statusExtra: statusExtra || undefined, modeSwitchable: modeSwitchable, children: children })] }));
212
+ }
213
+ /** History, line in progress, current action, checkpoint panel and status bar. */
214
+ export function SessionView({ model, interaction, renderApproval, mode, closed, fullscreen, header, onCopy, modeSwitchable, children }) {
215
+ const session = useSyncExternalStore(model.subscribe, model.getSnapshot);
216
+ const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
217
+ // Nothing in the live area may reach the terminal's edge (see lineBuffer.ts).
218
+ const width = liveWidth(useStdout().stdout.columns);
219
+ if (fullscreen) {
220
+ return closed ? null : (_jsx(FullscreenSession, { session: session, checkpoint: checkpoint, renderApproval: renderApproval, mode: mode, header: header, onCopy: onCopy, modeSwitchable: modeSwitchable, children: children }));
221
+ }
222
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: session.items, children: (item) => _jsx(Text, { children: inlineItem(item, session.expanded, width) }, item.id) }), closed ? null : (_jsx(LiveArea, { session: session, checkpoint: checkpoint, renderApproval: renderApproval, mode: mode, width: width, modeSwitchable: modeSwitchable, children: children }))] }));
223
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Files under `root` for `@` mentions, as paths relative to it with forward slashes. Skips
3
+ * `node_modules` and dot folders (`.git`, `.run`...), and stops at `limit` files so a huge
4
+ * tree can't stall the chat.
5
+ */
6
+ export declare function listProjectFiles(root: string, limit?: number): Promise<string[]>;
7
+ /** The `@mention` being typed right before the cursor: where it starts and what follows the `@`. */
8
+ export declare function mentionAt(value: string, cursor: number): {
9
+ start: number;
10
+ query: string;
11
+ } | null;
12
+ /** Files containing `query` (case-insensitive), the ones whose name starts with it first, then shorter paths. */
13
+ export declare function matchingFiles(files: readonly string[], query: string, max?: number): string[];
@@ -0,0 +1,50 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const SKIPPED = new Set(["node_modules"]);
4
+ /**
5
+ * Files under `root` for `@` mentions, as paths relative to it with forward slashes. Skips
6
+ * `node_modules` and dot folders (`.git`, `.run`...), and stops at `limit` files so a huge
7
+ * tree can't stall the chat.
8
+ */
9
+ export async function listProjectFiles(root, limit = 5000) {
10
+ const files = [];
11
+ const pending = [""];
12
+ while (pending.length > 0 && files.length < limit) {
13
+ const dir = pending.shift();
14
+ let entries;
15
+ try {
16
+ entries = await readdir(path.join(root, dir), { withFileTypes: true });
17
+ }
18
+ catch {
19
+ continue;
20
+ }
21
+ for (const entry of entries) {
22
+ if (entry.name.startsWith(".") || SKIPPED.has(entry.name))
23
+ continue;
24
+ const relative = dir ? `${dir}/${entry.name}` : entry.name;
25
+ if (entry.isDirectory())
26
+ pending.push(relative);
27
+ else if (entry.isFile())
28
+ files.push(relative);
29
+ if (files.length >= limit)
30
+ break;
31
+ }
32
+ }
33
+ return files.sort();
34
+ }
35
+ /** The `@mention` being typed right before the cursor: where it starts and what follows the `@`. */
36
+ export function mentionAt(value, cursor) {
37
+ const match = /(^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
38
+ if (!match)
39
+ return null;
40
+ return { start: cursor - match[2].length - 1, query: match[2] };
41
+ }
42
+ /** Files containing `query` (case-insensitive), the ones whose name starts with it first, then shorter paths. */
43
+ export function matchingFiles(files, query, max = 6) {
44
+ const needle = query.toLowerCase();
45
+ const name = (file) => file.slice(file.lastIndexOf("/") + 1).toLowerCase();
46
+ return files
47
+ .filter((file) => file.toLowerCase().includes(needle))
48
+ .sort((a, b) => Number(!name(a).startsWith(needle)) - Number(!name(b).startsWith(needle)) || a.length - b.length || a.localeCompare(b))
49
+ .slice(0, max);
50
+ }