@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/assets/knowledge-plugin/.claude-plugin/plugin.json +4 -0
- package/assets/knowledge-plugin/commands/ingest.md +7 -0
- package/assets/knowledge-plugin/commands/lint.md +5 -0
- package/assets/knowledge-plugin/commands/query.md +5 -0
- package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +42 -0
- package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +26 -0
- package/assets/knowledge-plugin/skills/knowledge-pages/SKILL.md +137 -0
- package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +14 -0
- package/dist/core/agentSpec.d.ts +132 -0
- package/dist/core/agentSpec.js +1 -0
- package/dist/core/claudeAuth.d.ts +24 -0
- package/dist/core/claudeAuth.js +27 -0
- package/dist/core/hooks/fileScopeGate.d.ts +33 -0
- package/dist/core/hooks/fileScopeGate.js +68 -0
- package/dist/core/hooks/humanInput.d.ts +15 -0
- package/dist/core/hooks/humanInput.js +52 -0
- package/dist/core/hooks/stepGate.d.ts +11 -0
- package/dist/core/hooks/stepGate.js +47 -0
- package/dist/core/hooks/subagentBashGate.d.ts +28 -0
- package/dist/core/hooks/subagentBashGate.js +40 -0
- package/dist/core/hooks/subagentForegroundGate.d.ts +18 -0
- package/dist/core/hooks/subagentForegroundGate.js +34 -0
- package/dist/core/hooks/subagentTypeGate.d.ts +16 -0
- package/dist/core/hooks/subagentTypeGate.js +33 -0
- package/dist/core/hooks/transcriptLogger.d.ts +14 -0
- package/dist/core/hooks/transcriptLogger.js +66 -0
- package/dist/core/interaction.d.ts +29 -0
- package/dist/core/interaction.js +7 -0
- package/dist/core/knowledge.d.ts +15 -0
- package/dist/core/knowledge.js +54 -0
- package/dist/core/mcpPermissions.d.ts +23 -0
- package/dist/core/mcpPermissions.js +47 -0
- package/dist/core/promptTemplate.d.ts +6 -0
- package/dist/core/promptTemplate.js +20 -0
- package/dist/core/runner.d.ts +137 -0
- package/dist/core/runner.js +115 -0
- package/dist/core/session.d.ts +63 -0
- package/dist/core/session.js +222 -0
- package/dist/core/toolLabels.d.ts +23 -0
- package/dist/core/toolLabels.js +114 -0
- package/dist/core/tools/humanApproval.d.ts +14 -0
- package/dist/core/tools/humanApproval.js +37 -0
- package/dist/core/tools/manualLogin.d.ts +16 -0
- package/dist/core/tools/manualLogin.js +27 -0
- package/dist/core/tools/saveToSources.d.ts +10 -0
- package/dist/core/tools/saveToSources.js +105 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +32 -0
- package/dist/tui/chatTui.d.ts +105 -0
- package/dist/tui/chatTui.js +265 -0
- package/dist/tui/claudeAuth.d.ts +19 -0
- package/dist/tui/claudeAuth.js +96 -0
- package/dist/tui/consoleRenderer.d.ts +32 -0
- package/dist/tui/consoleRenderer.js +72 -0
- package/dist/tui/ink/PromptInput.d.ts +40 -0
- package/dist/tui/ink/PromptInput.js +333 -0
- package/dist/tui/ink/SessionView.d.ts +42 -0
- package/dist/tui/ink/SessionView.js +223 -0
- package/dist/tui/ink/fileMentions.d.ts +13 -0
- package/dist/tui/ink/fileMentions.js +50 -0
- package/dist/tui/ink/fullscreen.d.ts +79 -0
- package/dist/tui/ink/fullscreen.js +175 -0
- package/dist/tui/ink/header.d.ts +17 -0
- package/dist/tui/ink/header.js +24 -0
- package/dist/tui/ink/inkInteraction.d.ts +22 -0
- package/dist/tui/ink/inkInteraction.js +64 -0
- package/dist/tui/ink/lineBuffer.d.ts +28 -0
- package/dist/tui/ink/lineBuffer.js +87 -0
- package/dist/tui/ink/markdown.d.ts +12 -0
- package/dist/tui/ink/markdown.js +148 -0
- package/dist/tui/ink/progressView.d.ts +27 -0
- package/dist/tui/ink/progressView.js +65 -0
- package/dist/tui/ink/promptEditing.d.ts +54 -0
- package/dist/tui/ink/promptEditing.js +123 -0
- package/dist/tui/ink/runChatInk.d.ts +103 -0
- package/dist/tui/ink/runChatInk.js +298 -0
- package/dist/tui/ink/sessionModel.d.ts +85 -0
- package/dist/tui/ink/sessionModel.js +262 -0
- package/dist/tui/ink/terminalCursor.d.ts +32 -0
- package/dist/tui/ink/terminalCursor.js +68 -0
- package/dist/tui/ink/terminalStatus.d.ts +20 -0
- package/dist/tui/ink/terminalStatus.js +55 -0
- package/dist/tui/ink/toolGroup.d.ts +36 -0
- package/dist/tui/ink/toolGroup.js +77 -0
- package/dist/tui/ink/wizard.d.ts +62 -0
- package/dist/tui/ink/wizard.js +167 -0
- package/dist/tui/promptErrors.d.ts +2 -0
- package/dist/tui/promptErrors.js +4 -0
- package/dist/tui/terminalInteraction.d.ts +18 -0
- package/dist/tui/terminalInteraction.js +85 -0
- package/dist/tui/ui.d.ts +21 -0
- package/dist/tui/ui.js +25 -0
- package/package.json +75 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switches `stream` to the full screen and returns the function that restores it. The
|
|
3
|
+
* restore runs once, whichever comes first: the caller, or the process exiting (an
|
|
4
|
+
* uncaught error, `process.exit()`), since a terminal left in the alternate screen with
|
|
5
|
+
* mouse reporting on is unusable until reset.
|
|
6
|
+
*/
|
|
7
|
+
export declare function enterFullscreen(stream: NodeJS.WriteStream): () => void;
|
|
8
|
+
export interface MouseEvent {
|
|
9
|
+
kind: "press" | "drag" | "release" | "right" | "wheel-up" | "wheel-down" | "other";
|
|
10
|
+
/** 0-based column and row of the cell. */
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}
|
|
14
|
+
/** The left-button presses, drags and releases, right-button presses and wheel steps in `input`, in order. */
|
|
15
|
+
export declare function mouseEvents(input: string): MouseEvent[];
|
|
16
|
+
/** True when `input` is only mouse reports (clicks, wheel), never text to type. */
|
|
17
|
+
export declare function isMouseReport(input: string): boolean;
|
|
18
|
+
/** Wheel movement in `input`: negative scrolls up, positive down, 0 if none. */
|
|
19
|
+
export declare function wheelSteps(input: string): number;
|
|
20
|
+
export interface WrappedRows {
|
|
21
|
+
rows: string[];
|
|
22
|
+
/** Whether each row continues the line of the row before it (it was wrapped there). */
|
|
23
|
+
continued: boolean[];
|
|
24
|
+
}
|
|
25
|
+
/** A history line as the row cache needs it (see sessionModel.ts's HistoryItem). */
|
|
26
|
+
export interface RowSource {
|
|
27
|
+
text: string;
|
|
28
|
+
kind?: string;
|
|
29
|
+
expanded?: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The human's line as a full-width gray bar, bold, as in the Claude Code CLI: wrapped to
|
|
33
|
+
* `width` and each row padded in columns, so the bar is even whatever the text holds.
|
|
34
|
+
*/
|
|
35
|
+
export declare function userBarRows(text: string, width: number): string[];
|
|
36
|
+
/**
|
|
37
|
+
* The history's rows, wrapped to `width`, with a cache that only wraps the lines added
|
|
38
|
+
* since the last call: the history only grows, and re-wrapping every line on every frame
|
|
39
|
+
* (up to 30 a second while a reply streams) would get slow in a long session. A different
|
|
40
|
+
* width, a change of `expand` (Ctrl+O), or a history that didn't just grow, wraps
|
|
41
|
+
* everything again. Tool groups show their unfolded lines when `expand` is on, and the
|
|
42
|
+
* human's lines are drawn as bars.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createRowCache(): (lines: readonly RowSource[], width: number, expand?: boolean) => WrappedRows;
|
|
45
|
+
/** A cell of the history: a row index and a 0-based column. */
|
|
46
|
+
export interface Cell {
|
|
47
|
+
row: number;
|
|
48
|
+
col: number;
|
|
49
|
+
}
|
|
50
|
+
/** A selection from where the drag started to where it is now, in either order. */
|
|
51
|
+
export interface Selection {
|
|
52
|
+
anchor: Cell;
|
|
53
|
+
focus: Cell;
|
|
54
|
+
}
|
|
55
|
+
/** The selection's first and last cells, in reading order. */
|
|
56
|
+
export declare function orderedCells({ anchor, focus }: Selection): [Cell, Cell];
|
|
57
|
+
/** Columns [from, to) of `row` inside the selection, or null if the row is outside it. */
|
|
58
|
+
export declare function selectedColumns(selection: Selection, row: number, rowWidth: number): [number, number] | null;
|
|
59
|
+
/** `row` with the selected columns drawn in inverse video. */
|
|
60
|
+
export declare function highlightRow(row: string, selection: Selection | null, index: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* The selected text, plain: rows joined with line breaks, except where a row only continues
|
|
63
|
+
* a wrapped line; trailing spaces of each line dropped.
|
|
64
|
+
*/
|
|
65
|
+
export declare function selectedText(wrapped: WrappedRows, selection: Selection): string;
|
|
66
|
+
/** OSC 52: sets the system clipboard from the terminal (Windows Terminal supports it). */
|
|
67
|
+
export declare function clipboardSequence(text: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* Where the history view sits: the index of the last row shown, or null to follow the
|
|
70
|
+
* bottom. Anchoring to a row (not to a distance from the bottom) keeps the view still
|
|
71
|
+
* while new output arrives below it.
|
|
72
|
+
*/
|
|
73
|
+
export type ScrollAnchor = number | null;
|
|
74
|
+
/** The rows [start, end) shown in `height` rows. */
|
|
75
|
+
export declare function visibleRows(total: number, height: number, anchor: ScrollAnchor): [number, number];
|
|
76
|
+
/** Moves the view by `delta` rows (negative is up), never above the first page; back to following the bottom once it gets there. */
|
|
77
|
+
export declare function scrollBy(anchor: ScrollAnchor, delta: number, total: number, height: number): ScrollAnchor;
|
|
78
|
+
/** Rows below the view (0 when following the bottom). */
|
|
79
|
+
export declare function rowsBelow(anchor: ScrollAnchor, total: number): number;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import sliceAnsi from "slice-ansi";
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
import wrapAnsi from "wrap-ansi";
|
|
4
|
+
import { stripAnsi } from "./lineBuffer.js";
|
|
5
|
+
import * as ui from "../ui.js";
|
|
6
|
+
// Alternate screen plus mouse reporting in SGR form: button events with drags (?1002h), so
|
|
7
|
+
// the wheel scrolls and dragging selects text (see the feature ink-selection).
|
|
8
|
+
const ENTER = "\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
9
|
+
// Back to the normal screen, which brings back what was there before; then clear it and
|
|
10
|
+
// put the cursor at the top, keeping the scrollback (like `cls` / `clear`).
|
|
11
|
+
const LEAVE = "\x1b[?1002l\x1b[?1000l\x1b[?1006l\x1b[?1049l\x1b[2J\x1b[H";
|
|
12
|
+
/**
|
|
13
|
+
* Switches `stream` to the full screen and returns the function that restores it. The
|
|
14
|
+
* restore runs once, whichever comes first: the caller, or the process exiting (an
|
|
15
|
+
* uncaught error, `process.exit()`), since a terminal left in the alternate screen with
|
|
16
|
+
* mouse reporting on is unusable until reset.
|
|
17
|
+
*/
|
|
18
|
+
export function enterFullscreen(stream) {
|
|
19
|
+
stream.write(ENTER);
|
|
20
|
+
let restored = false;
|
|
21
|
+
const restore = () => {
|
|
22
|
+
if (restored)
|
|
23
|
+
return;
|
|
24
|
+
restored = true;
|
|
25
|
+
process.off("exit", restore);
|
|
26
|
+
stream.write(LEAVE);
|
|
27
|
+
};
|
|
28
|
+
process.on("exit", restore);
|
|
29
|
+
return restore;
|
|
30
|
+
}
|
|
31
|
+
// An SGR mouse report as Ink hands it to useInput: "[<button;x;yM" (press, or drag) or
|
|
32
|
+
// "...m" (release), with or without the leading ESC; x and y are 1-based cells. Wheel up is
|
|
33
|
+
// button 64, wheel down 65; the left button is 0 (32 is added while it drags), the right one 2.
|
|
34
|
+
// eslint-disable-next-line no-control-regex -- \x1b is the ESC byte the report may start with
|
|
35
|
+
const MOUSE_REPORT = /\x1b?\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
36
|
+
/** The left-button presses, drags and releases, right-button presses and wheel steps in `input`, in order. */
|
|
37
|
+
export function mouseEvents(input) {
|
|
38
|
+
return [...input.matchAll(MOUSE_REPORT)].map((match) => {
|
|
39
|
+
const button = Number(match[1]);
|
|
40
|
+
const x = Number(match[2]) - 1;
|
|
41
|
+
const y = Number(match[3]) - 1;
|
|
42
|
+
const kind = button === 64 ? "wheel-up"
|
|
43
|
+
: button === 65 ? "wheel-down"
|
|
44
|
+
: button === 0 && match[4] === "m" ? "release"
|
|
45
|
+
: button === 0 ? "press"
|
|
46
|
+
: button === 32 ? "drag"
|
|
47
|
+
: button === 2 && match[4] === "M" ? "right"
|
|
48
|
+
: "other";
|
|
49
|
+
return { kind, x, y };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** True when `input` is only mouse reports (clicks, wheel), never text to type. */
|
|
53
|
+
export function isMouseReport(input) {
|
|
54
|
+
return input.length > 0 && input.replace(MOUSE_REPORT, "") === "";
|
|
55
|
+
}
|
|
56
|
+
/** Wheel movement in `input`: negative scrolls up, positive down, 0 if none. */
|
|
57
|
+
export function wheelSteps(input) {
|
|
58
|
+
let steps = 0;
|
|
59
|
+
for (const match of input.matchAll(MOUSE_REPORT)) {
|
|
60
|
+
if (match[1] === "64")
|
|
61
|
+
steps--;
|
|
62
|
+
else if (match[1] === "65")
|
|
63
|
+
steps++;
|
|
64
|
+
}
|
|
65
|
+
return steps;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The human's line as a full-width gray bar, bold, as in the Claude Code CLI: wrapped to
|
|
69
|
+
* `width` and each row padded in columns, so the bar is even whatever the text holds.
|
|
70
|
+
*/
|
|
71
|
+
export function userBarRows(text, width) {
|
|
72
|
+
return wrapAnsi(text, Math.max(1, width - 1), { hard: true, trim: false })
|
|
73
|
+
.split("\n")
|
|
74
|
+
.map((row) => ui.userBar(ui.bold(` ${row}${" ".repeat(Math.max(0, width - 1 - stringWidth(stripAnsi(row))))}`)));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The history's rows, wrapped to `width`, with a cache that only wraps the lines added
|
|
78
|
+
* since the last call: the history only grows, and re-wrapping every line on every frame
|
|
79
|
+
* (up to 30 a second while a reply streams) would get slow in a long session. A different
|
|
80
|
+
* width, a change of `expand` (Ctrl+O), or a history that didn't just grow, wraps
|
|
81
|
+
* everything again. Tool groups show their unfolded lines when `expand` is on, and the
|
|
82
|
+
* human's lines are drawn as bars.
|
|
83
|
+
*/
|
|
84
|
+
export function createRowCache() {
|
|
85
|
+
let cachedWidth = -1;
|
|
86
|
+
let cachedExpand = false;
|
|
87
|
+
let cachedLines = [];
|
|
88
|
+
let result = { rows: [], continued: [] };
|
|
89
|
+
return (lines, width, expand = false) => {
|
|
90
|
+
const grew = width === cachedWidth &&
|
|
91
|
+
expand === cachedExpand &&
|
|
92
|
+
lines.length >= cachedLines.length &&
|
|
93
|
+
lines[cachedLines.length - 1] === cachedLines.at(-1);
|
|
94
|
+
const fresh = grew ? lines.slice(cachedLines.length) : lines;
|
|
95
|
+
const rows = [];
|
|
96
|
+
const continued = [];
|
|
97
|
+
for (const line of fresh) {
|
|
98
|
+
const texts = expand && line.expanded ? line.expanded : [line.text];
|
|
99
|
+
for (const text of texts) {
|
|
100
|
+
const wrapped = line.kind === "user" ? userBarRows(text, width) : wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
|
|
101
|
+
wrapped.forEach((row, i) => {
|
|
102
|
+
rows.push(row);
|
|
103
|
+
continued.push(i > 0);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
result = grew ? { rows: result.rows.concat(rows), continued: result.continued.concat(continued) } : { rows, continued };
|
|
108
|
+
cachedWidth = width;
|
|
109
|
+
cachedExpand = expand;
|
|
110
|
+
cachedLines = lines;
|
|
111
|
+
return result;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The selection's first and last cells, in reading order. */
|
|
115
|
+
export function orderedCells({ anchor, focus }) {
|
|
116
|
+
const anchorFirst = anchor.row < focus.row || (anchor.row === focus.row && anchor.col <= focus.col);
|
|
117
|
+
return anchorFirst ? [anchor, focus] : [focus, anchor];
|
|
118
|
+
}
|
|
119
|
+
/** Columns [from, to) of `row` inside the selection, or null if the row is outside it. */
|
|
120
|
+
export function selectedColumns(selection, row, rowWidth) {
|
|
121
|
+
const [first, last] = orderedCells(selection);
|
|
122
|
+
if (row < first.row || row > last.row)
|
|
123
|
+
return null;
|
|
124
|
+
const from = row === first.row ? first.col : 0;
|
|
125
|
+
const to = row === last.row ? last.col + 1 : rowWidth;
|
|
126
|
+
return from < to ? [from, Math.min(to, Math.max(from, rowWidth))] : null;
|
|
127
|
+
}
|
|
128
|
+
/** `row` with the selected columns drawn in inverse video. */
|
|
129
|
+
export function highlightRow(row, selection, index) {
|
|
130
|
+
if (!selection)
|
|
131
|
+
return row;
|
|
132
|
+
const columns = selectedColumns(selection, index, stringWidth(stripAnsi(row)));
|
|
133
|
+
if (!columns)
|
|
134
|
+
return row;
|
|
135
|
+
const [from, to] = columns;
|
|
136
|
+
return `${sliceAnsi(row, 0, from)}\x1b[7m${stripAnsi(sliceAnsi(row, from, to))}\x1b[27m${sliceAnsi(row, to)}`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The selected text, plain: rows joined with line breaks, except where a row only continues
|
|
140
|
+
* a wrapped line; trailing spaces of each line dropped.
|
|
141
|
+
*/
|
|
142
|
+
export function selectedText(wrapped, selection) {
|
|
143
|
+
const [first, last] = orderedCells(selection);
|
|
144
|
+
let text = "";
|
|
145
|
+
for (let row = first.row; row <= last.row && row < wrapped.rows.length; row++) {
|
|
146
|
+
const plain = stripAnsi(wrapped.rows[row]);
|
|
147
|
+
const columns = selectedColumns(selection, row, stringWidth(plain));
|
|
148
|
+
const piece = columns ? sliceAnsi(plain, columns[0], columns[1]) : "";
|
|
149
|
+
if (row > first.row && !wrapped.continued[row])
|
|
150
|
+
text = `${text.trimEnd()}\n`;
|
|
151
|
+
text += piece;
|
|
152
|
+
}
|
|
153
|
+
return text.trimEnd();
|
|
154
|
+
}
|
|
155
|
+
/** OSC 52: sets the system clipboard from the terminal (Windows Terminal supports it). */
|
|
156
|
+
export function clipboardSequence(text) {
|
|
157
|
+
return `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
|
|
158
|
+
}
|
|
159
|
+
/** The rows [start, end) shown in `height` rows. */
|
|
160
|
+
export function visibleRows(total, height, anchor) {
|
|
161
|
+
const end = anchor === null ? total : Math.min(total, anchor + 1);
|
|
162
|
+
return [Math.max(0, end - height), end];
|
|
163
|
+
}
|
|
164
|
+
/** Moves the view by `delta` rows (negative is up), never above the first page; back to following the bottom once it gets there. */
|
|
165
|
+
export function scrollBy(anchor, delta, total, height) {
|
|
166
|
+
if (total <= height)
|
|
167
|
+
return null;
|
|
168
|
+
const current = anchor ?? total - 1;
|
|
169
|
+
const next = Math.min(total - 1, Math.max(height - 1, current + delta));
|
|
170
|
+
return next >= total - 1 ? null : next;
|
|
171
|
+
}
|
|
172
|
+
/** Rows below the view (0 when following the bottom). */
|
|
173
|
+
export function rowsBelow(anchor, total) {
|
|
174
|
+
return anchor === null ? 0 : Math.max(0, total - 1 - anchor);
|
|
175
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface HeaderInfo {
|
|
2
|
+
title: string;
|
|
3
|
+
/** Shown under the title as "name value" pairs, e.g. the workspace or the model. */
|
|
4
|
+
fields?: Record<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* A small logo drawn left of the title, one string per row, colored as the consumer
|
|
7
|
+
* likes. One-column characters only (ASCII, box and block characters such as ▄ ▀ █),
|
|
8
|
+
* never emoji: their width is measured differently by the kit and by the terminal, which
|
|
9
|
+
* would shift the title (see the feature `ink-claude-style`).
|
|
10
|
+
*/
|
|
11
|
+
art?: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The header as screen lines: the art on the left, padded to its widest row in columns,
|
|
15
|
+
* and the title and fields beside it, vertically centered on the art.
|
|
16
|
+
*/
|
|
17
|
+
export declare function headerLines(header: HeaderInfo): string[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import { stripAnsi } from "./lineBuffer.js";
|
|
3
|
+
import * as ui from "../ui.js";
|
|
4
|
+
const GAP = " ";
|
|
5
|
+
/**
|
|
6
|
+
* The header as screen lines: the art on the left, padded to its widest row in columns,
|
|
7
|
+
* and the title and fields beside it, vertically centered on the art.
|
|
8
|
+
*/
|
|
9
|
+
export function headerLines(header) {
|
|
10
|
+
const fields = Object.entries(header.fields ?? {}).map(([name, value]) => `${ui.dim(name)} ${value}`);
|
|
11
|
+
const text = [ui.heading(header.title), ...(fields.length > 0 ? [fields.join(" ")] : [])];
|
|
12
|
+
const art = header.art ?? [];
|
|
13
|
+
if (art.length === 0)
|
|
14
|
+
return text;
|
|
15
|
+
const artWidth = Math.max(...art.map((row) => stringWidth(stripAnsi(row))));
|
|
16
|
+
const height = Math.max(art.length, text.length);
|
|
17
|
+
const textTop = Math.floor((height - text.length) / 2);
|
|
18
|
+
return Array.from({ length: height }, (_, row) => {
|
|
19
|
+
const left = art[row] ?? "";
|
|
20
|
+
const padding = " ".repeat(artWidth - stringWidth(stripAnsi(left)));
|
|
21
|
+
const right = text[row - textTop] ?? "";
|
|
22
|
+
return right ? `${left}${padding}${GAP}${right}` : left;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ApprovalPrompt, InteractionPort } from "../../core/interaction.js";
|
|
2
|
+
export type CheckpointKind = "decision" | "manual-intervention";
|
|
3
|
+
export interface Checkpoint {
|
|
4
|
+
id: number;
|
|
5
|
+
kind: CheckpointKind;
|
|
6
|
+
prompt: ApprovalPrompt;
|
|
7
|
+
/** Settles the checkpoint with the raw answer ("y", "n", "q" for a decision). */
|
|
8
|
+
answer(value: string): void;
|
|
9
|
+
}
|
|
10
|
+
export interface InkInteraction {
|
|
11
|
+
port: InteractionPort;
|
|
12
|
+
subscribe(listener: () => void): () => void;
|
|
13
|
+
/** The checkpoint on screen, or null. */
|
|
14
|
+
getSnapshot(): Checkpoint | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* An `InteractionPort` for the Ink views: each checkpoint waits in a queue (the step gate
|
|
18
|
+
* can fire for several tool calls at once) until the panel answers it, or until the
|
|
19
|
+
* response file does and the signal drops it. Once settled it is written to the history
|
|
20
|
+
* through `note` — like the terminal port, it never reaches the session log.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createInkInteraction(note: (text: string) => void): InkInteraction;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as ui from "../ui.js";
|
|
2
|
+
const OUTCOMES = {
|
|
3
|
+
y: ui.success("✔ Approved"),
|
|
4
|
+
n: ui.warn("✘ Rejected"),
|
|
5
|
+
q: ui.error("■ Stopped"),
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* An `InteractionPort` for the Ink views: each checkpoint waits in a queue (the step gate
|
|
9
|
+
* can fire for several tool calls at once) until the panel answers it, or until the
|
|
10
|
+
* response file does and the signal drops it. Once settled it is written to the history
|
|
11
|
+
* through `note` — like the terminal port, it never reaches the session log.
|
|
12
|
+
*/
|
|
13
|
+
export function createInkInteraction(note) {
|
|
14
|
+
const listeners = new Set();
|
|
15
|
+
let queue = [];
|
|
16
|
+
let nextId = 0;
|
|
17
|
+
function emit() {
|
|
18
|
+
for (const listener of listeners)
|
|
19
|
+
listener();
|
|
20
|
+
}
|
|
21
|
+
function remove(checkpoint) {
|
|
22
|
+
if (!queue.includes(checkpoint))
|
|
23
|
+
return false;
|
|
24
|
+
queue = queue.filter((c) => c !== checkpoint);
|
|
25
|
+
emit();
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function record(prompt, outcome) {
|
|
29
|
+
note([ui.heading(`=== ${prompt.title} ===`), ...prompt.lines, outcome].join("\n"));
|
|
30
|
+
}
|
|
31
|
+
function ask(kind, prompt, signal) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const checkpoint = {
|
|
34
|
+
id: nextId++,
|
|
35
|
+
kind,
|
|
36
|
+
prompt,
|
|
37
|
+
answer(value) {
|
|
38
|
+
if (!remove(checkpoint))
|
|
39
|
+
return;
|
|
40
|
+
record(prompt, kind === "decision" ? (OUTCOMES[value] ?? value) : ui.success("✔ Done"));
|
|
41
|
+
resolve(value);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
signal.addEventListener("abort", () => {
|
|
45
|
+
if (remove(checkpoint))
|
|
46
|
+
record(prompt, ui.dim("(answered through the response file)"));
|
|
47
|
+
}, { once: true });
|
|
48
|
+
queue = [...queue, checkpoint];
|
|
49
|
+
emit();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
port: {
|
|
54
|
+
askDecision: (prompt, signal) => ask("decision", prompt, signal),
|
|
55
|
+
askManualIntervention: (prompt, signal) => ask("manual-intervention", prompt, signal),
|
|
56
|
+
notify: (message) => note(message),
|
|
57
|
+
},
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => listeners.delete(listener);
|
|
61
|
+
},
|
|
62
|
+
getSnapshot: () => queue[0] ?? null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare function stripAnsi(text: string): string;
|
|
2
|
+
export interface LineBuffer {
|
|
3
|
+
/** Appends `text`; returns the lines it completed, each self-contained in its colors. */
|
|
4
|
+
push(text: string): string[];
|
|
5
|
+
/** The line still being written (e.g. a reply mid-stream), with its colors reopened. */
|
|
6
|
+
readonly partial: string;
|
|
7
|
+
/**
|
|
8
|
+
* Cuts the line in progress into rows of at most `width` columns, at word boundaries,
|
|
9
|
+
* and returns every full row, leaving only the last one in progress.
|
|
10
|
+
*/
|
|
11
|
+
wrap(width: number): string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Splits the console renderer's output into whole lines, for Ink's `<Static>`, plus the
|
|
15
|
+
* line in progress, for the live area. A streamed reply is colored per delta, and a delta
|
|
16
|
+
* often spans a newline, so a split line would leave its color open on one side and lost
|
|
17
|
+
* on the other: each completed line is closed with a reset and the next one reopens
|
|
18
|
+
* whatever was still active.
|
|
19
|
+
*
|
|
20
|
+
* `wrap()` exists because Ink redraws the live area by erasing as many rows as it believes
|
|
21
|
+
* it drew. A line in progress that reaches the terminal's edge can take one row more than
|
|
22
|
+
* Ink computed, and every redraw then leaves a stale copy of it behind (seen on Windows: a
|
|
23
|
+
* streamed reply repeated once per spinner frame; likely the console wrapping as soon as the
|
|
24
|
+
* last column is written, or drawing some emoji wider). Rows cut short of the edge don't.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createLineBuffer(): LineBuffer;
|
|
27
|
+
/** `text` on one row of at most `width` columns, cut with "…" when it doesn't fit. */
|
|
28
|
+
export declare function fitWidth(text: string, width: number): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import wrapAnsi from "wrap-ansi";
|
|
2
|
+
// eslint-disable-next-line no-control-regex -- \x1b is the ESC byte SGR sequences start with, not an accident
|
|
3
|
+
const SGR = /\x1b\[([0-9;]*)m/g;
|
|
4
|
+
// Which attribute each picocolors code opens, and which attributes each code closes.
|
|
5
|
+
const OPENS = {};
|
|
6
|
+
for (const code of [30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97])
|
|
7
|
+
OPENS[code] = "fg";
|
|
8
|
+
for (const code of [40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107])
|
|
9
|
+
OPENS[code] = "bg";
|
|
10
|
+
Object.assign(OPENS, { 1: "bold", 2: "dim", 3: "italic", 4: "underline", 7: "inverse", 8: "hidden", 9: "strike" });
|
|
11
|
+
const CLOSES = {
|
|
12
|
+
39: ["fg"],
|
|
13
|
+
49: ["bg"],
|
|
14
|
+
22: ["bold", "dim"],
|
|
15
|
+
23: ["italic"],
|
|
16
|
+
24: ["underline"],
|
|
17
|
+
27: ["inverse"],
|
|
18
|
+
28: ["hidden"],
|
|
19
|
+
29: ["strike"],
|
|
20
|
+
};
|
|
21
|
+
/** The SGR codes still open at the end of `text`, as a prefix that reopens them. */
|
|
22
|
+
function openStyles(text) {
|
|
23
|
+
const active = new Map();
|
|
24
|
+
for (const match of text.matchAll(SGR)) {
|
|
25
|
+
for (const code of (match[1] || "0").split(";")) {
|
|
26
|
+
if (code === "0" || code === "")
|
|
27
|
+
active.clear();
|
|
28
|
+
else if (OPENS[code])
|
|
29
|
+
active.set(OPENS[code], code);
|
|
30
|
+
else
|
|
31
|
+
for (const attribute of CLOSES[code] ?? [])
|
|
32
|
+
active.delete(attribute);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return [...active.values()].map((code) => `\x1b[${code}m`).join("");
|
|
36
|
+
}
|
|
37
|
+
export function stripAnsi(text) {
|
|
38
|
+
return text.replace(SGR, "");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Splits the console renderer's output into whole lines, for Ink's `<Static>`, plus the
|
|
42
|
+
* line in progress, for the live area. A streamed reply is colored per delta, and a delta
|
|
43
|
+
* often spans a newline, so a split line would leave its color open on one side and lost
|
|
44
|
+
* on the other: each completed line is closed with a reset and the next one reopens
|
|
45
|
+
* whatever was still active.
|
|
46
|
+
*
|
|
47
|
+
* `wrap()` exists because Ink redraws the live area by erasing as many rows as it believes
|
|
48
|
+
* it drew. A line in progress that reaches the terminal's edge can take one row more than
|
|
49
|
+
* Ink computed, and every redraw then leaves a stale copy of it behind (seen on Windows: a
|
|
50
|
+
* streamed reply repeated once per spinner frame; likely the console wrapping as soon as the
|
|
51
|
+
* last column is written, or drawing some emoji wider). Rows cut short of the edge don't.
|
|
52
|
+
*/
|
|
53
|
+
export function createLineBuffer() {
|
|
54
|
+
let partial = "";
|
|
55
|
+
return {
|
|
56
|
+
push(text) {
|
|
57
|
+
const parts = (partial + text).split("\n");
|
|
58
|
+
partial = parts.pop() ?? "";
|
|
59
|
+
const lines = [];
|
|
60
|
+
let carry = "";
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
const line = carry + part;
|
|
63
|
+
carry = openStyles(line);
|
|
64
|
+
lines.push(carry ? `${line}\x1b[0m` : line);
|
|
65
|
+
}
|
|
66
|
+
if (lines.length > 0)
|
|
67
|
+
partial = carry + partial;
|
|
68
|
+
return lines;
|
|
69
|
+
},
|
|
70
|
+
get partial() {
|
|
71
|
+
return partial;
|
|
72
|
+
},
|
|
73
|
+
wrap(width) {
|
|
74
|
+
// trim: false keeps the space a row ends on, which the next streamed word needs.
|
|
75
|
+
const rows = wrapAnsi(partial, width, { hard: true, trim: false }).split("\n");
|
|
76
|
+
if (rows.length <= 1)
|
|
77
|
+
return [];
|
|
78
|
+
partial = rows.pop() ?? "";
|
|
79
|
+
return rows;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** `text` on one row of at most `width` columns, cut with "…" when it doesn't fit. */
|
|
84
|
+
export function fitWidth(text, width) {
|
|
85
|
+
const rows = wrapAnsi(text, Math.max(1, width - 1), { wordWrap: false, trim: false }).split("\n");
|
|
86
|
+
return rows.length > 1 ? `${rows[0]}…` : rows[0];
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown to terminal lines, in the look of the Claude Code CLI: bold, italic, inline code
|
|
3
|
+
* in blue, lists, headings, quotes, rules, code blocks and tables, wrapped to `width`
|
|
4
|
+
* columns. `marked` only tokenizes; the drawing is ours, so it stays within the kit's
|
|
5
|
+
* alignment rules (one-column glyphs, widths in columns).
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderMarkdown(source: string, width: number): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Where the finished blocks end in a reply still streaming: after its last blank line
|
|
10
|
+
* outside a code fence. Everything before it renders for good; the rest is still growing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function finishedLength(source: string): number;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
import wrapAnsi from "wrap-ansi";
|
|
4
|
+
import { stripAnsi } from "./lineBuffer.js";
|
|
5
|
+
import * as ui from "../ui.js";
|
|
6
|
+
/**
|
|
7
|
+
* Markdown to terminal lines, in the look of the Claude Code CLI: bold, italic, inline code
|
|
8
|
+
* in blue, lists, headings, quotes, rules, code blocks and tables, wrapped to `width`
|
|
9
|
+
* columns. `marked` only tokenizes; the drawing is ours, so it stays within the kit's
|
|
10
|
+
* alignment rules (one-column glyphs, widths in columns).
|
|
11
|
+
*/
|
|
12
|
+
export function renderMarkdown(source, width) {
|
|
13
|
+
const tokens = marked.lexer(source, { gfm: true });
|
|
14
|
+
return renderBlocks(tokens, Math.max(10, width));
|
|
15
|
+
}
|
|
16
|
+
/** `gap`: a blank line between blocks (not inside a tight list item). */
|
|
17
|
+
function renderBlocks(tokens, width, gap = true) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
for (const token of tokens) {
|
|
20
|
+
const block = renderBlock(token, width);
|
|
21
|
+
if (block === null)
|
|
22
|
+
continue;
|
|
23
|
+
if (gap && lines.length > 0)
|
|
24
|
+
lines.push("");
|
|
25
|
+
lines.push(...block);
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
function wrap(text, width) {
|
|
30
|
+
return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
|
|
31
|
+
}
|
|
32
|
+
function renderBlock(token, width) {
|
|
33
|
+
switch (token.type) {
|
|
34
|
+
case "space":
|
|
35
|
+
case "def":
|
|
36
|
+
case "checkbox": // drawn by renderList() as "[x] " before the item's text
|
|
37
|
+
return null;
|
|
38
|
+
case "paragraph":
|
|
39
|
+
case "text":
|
|
40
|
+
return wrap(inline(token.tokens ?? [], token.text), width);
|
|
41
|
+
case "heading": {
|
|
42
|
+
const heading = token;
|
|
43
|
+
return wrap(ui.bold(inline(heading.tokens, heading.text)), width);
|
|
44
|
+
}
|
|
45
|
+
case "hr":
|
|
46
|
+
return [ui.dim("─".repeat(Math.min(width, 40)))];
|
|
47
|
+
case "code":
|
|
48
|
+
return renderCode(token, width);
|
|
49
|
+
case "blockquote": {
|
|
50
|
+
const inner = renderBlocks(token.tokens, width - 2);
|
|
51
|
+
return inner.map((line) => `${ui.dim("│")} ${line}`);
|
|
52
|
+
}
|
|
53
|
+
case "list":
|
|
54
|
+
return renderList(token, width);
|
|
55
|
+
case "table":
|
|
56
|
+
return renderTable(token, width);
|
|
57
|
+
case "html":
|
|
58
|
+
return wrap(token.raw.trimEnd(), width);
|
|
59
|
+
default:
|
|
60
|
+
return wrap(token.raw.trimEnd(), width);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function renderCode(token, width) {
|
|
64
|
+
return token.text.split("\n").flatMap((line) => wrap(ui.code(line), width - 2).map((row) => ` ${row}`));
|
|
65
|
+
}
|
|
66
|
+
function renderList(list, width) {
|
|
67
|
+
const start = typeof list.start === "number" ? list.start : 1;
|
|
68
|
+
const lines = [];
|
|
69
|
+
list.items.forEach((item, index) => {
|
|
70
|
+
const marker = list.ordered ? `${start + index}.` : "-";
|
|
71
|
+
const indent = stringWidth(marker) + 1;
|
|
72
|
+
const body = renderBlocks(item.tokens, width - indent, list.loose);
|
|
73
|
+
const box = item.task ? (item.checked ? "[x] " : "[ ] ") : "";
|
|
74
|
+
body.forEach((line, i) => {
|
|
75
|
+
lines.push(i === 0 ? `${marker} ${box}${line}` : `${" ".repeat(indent)}${line}`);
|
|
76
|
+
});
|
|
77
|
+
if (list.loose && index < list.items.length - 1)
|
|
78
|
+
lines.push("");
|
|
79
|
+
});
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
function renderTable(table, width) {
|
|
83
|
+
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));
|
|
92
|
+
}
|
|
93
|
+
/** Inline tokens (bold, italic, code, links...) as styled text. */
|
|
94
|
+
function inline(tokens, fallback) {
|
|
95
|
+
if (tokens.length === 0)
|
|
96
|
+
return fallback;
|
|
97
|
+
return tokens
|
|
98
|
+
.map((token) => {
|
|
99
|
+
switch (token.type) {
|
|
100
|
+
case "strong":
|
|
101
|
+
return ui.bold(inline(token.tokens, token.raw));
|
|
102
|
+
case "em":
|
|
103
|
+
return ui.italic(inline(token.tokens, token.raw));
|
|
104
|
+
case "del":
|
|
105
|
+
return ui.strike(inline(token.tokens, token.raw));
|
|
106
|
+
case "codespan":
|
|
107
|
+
return ui.code(token.text);
|
|
108
|
+
case "link": {
|
|
109
|
+
const link = token;
|
|
110
|
+
const text = inline(link.tokens, link.text);
|
|
111
|
+
return stripAnsi(text) === link.href ? ui.code(text) : `${ui.code(text)}${ui.dim(` (${link.href})`)}`;
|
|
112
|
+
}
|
|
113
|
+
case "image":
|
|
114
|
+
return ui.dim(`[${token.text}]`);
|
|
115
|
+
case "br":
|
|
116
|
+
return "\n";
|
|
117
|
+
case "escape":
|
|
118
|
+
return token.text;
|
|
119
|
+
case "text":
|
|
120
|
+
return token.tokens ? inline(token.tokens, token.text) : decodeEntities(token.text);
|
|
121
|
+
default:
|
|
122
|
+
return token.raw;
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.join("");
|
|
126
|
+
}
|
|
127
|
+
/** The few HTML entities marked leaves in text tokens. */
|
|
128
|
+
function decodeEntities(text) {
|
|
129
|
+
return text.replace(/&(amp|lt|gt|quot|#39);/g, (_, name) => ({ amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" })[name] ?? _);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Where the finished blocks end in a reply still streaming: after its last blank line
|
|
133
|
+
* outside a code fence. Everything before it renders for good; the rest is still growing.
|
|
134
|
+
*/
|
|
135
|
+
export function finishedLength(source) {
|
|
136
|
+
let fenceOpen = false;
|
|
137
|
+
let cut = 0;
|
|
138
|
+
let offset = 0;
|
|
139
|
+
for (const line of source.split("\n")) {
|
|
140
|
+
const end = offset + line.length + 1;
|
|
141
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
142
|
+
fenceOpen = !fenceOpen;
|
|
143
|
+
else if (!fenceOpen && line.trim() === "" && end <= source.length)
|
|
144
|
+
cut = end;
|
|
145
|
+
offset = end;
|
|
146
|
+
}
|
|
147
|
+
return cut;
|
|
148
|
+
}
|