@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,85 @@
|
|
|
1
|
+
import type { AgentEvent, SessionUsage } from "../../core/runner.js";
|
|
2
|
+
import { type ConsoleRenderer } from "../consoleRenderer.js";
|
|
3
|
+
import { type ToolPhrase } from "./toolGroup.js";
|
|
4
|
+
export interface HistoryItem {
|
|
5
|
+
id: number;
|
|
6
|
+
text: string;
|
|
7
|
+
/** What it is: the view draws the human's lines as a bar, and the spacing follows it. */
|
|
8
|
+
kind?: OutputKind;
|
|
9
|
+
/** A folded group of tool calls: the lines it unfolds into with Ctrl+O. */
|
|
10
|
+
expanded?: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What a block of output is: the human's line, the agent's words, a group of its tool
|
|
14
|
+
* calls, a notice (welcome, info, warnings), an error, or a note (header, checkpoints,
|
|
15
|
+
* the turn summary).
|
|
16
|
+
*/
|
|
17
|
+
export type OutputKind = "user" | "agent" | "action" | "notice" | "error" | "note";
|
|
18
|
+
export interface SessionSnapshot {
|
|
19
|
+
/** Finished lines: in Ink's `<Static>` inline, in the scrollable history in full screen. */
|
|
20
|
+
items: HistoryItem[];
|
|
21
|
+
/** What is still being written, one or more lines: the reply in progress or the open tool group. */
|
|
22
|
+
live: string;
|
|
23
|
+
/** The live block starts a new kind of output: shown with a blank line above it. */
|
|
24
|
+
liveGap: boolean;
|
|
25
|
+
/** A turn is in flight. */
|
|
26
|
+
busy: boolean;
|
|
27
|
+
/** When the turn in flight started (ms), for the spinner's elapsed time. */
|
|
28
|
+
turnStartedAt: number | null;
|
|
29
|
+
/** Label of the main agent's latest action in this turn. */
|
|
30
|
+
activity: string | null;
|
|
31
|
+
/** Label of the latest action taken inside a subagent in this turn. */
|
|
32
|
+
subagentActivity: string | null;
|
|
33
|
+
/** Tool calls shown one by one with their results (the default); Ctrl+O folds them into a summary line. */
|
|
34
|
+
expanded: boolean;
|
|
35
|
+
turns: number;
|
|
36
|
+
usage: SessionUsage | null;
|
|
37
|
+
/** How full the context window is (0-100), after the latest turn; null until known. */
|
|
38
|
+
contextPercent: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface SessionModelOptions {
|
|
41
|
+
formatAction?: (toolName: string, toolInput: unknown) => string;
|
|
42
|
+
/** How a tool counts in a folded group's summary ("read 2 files"); built-in tools have their own. */
|
|
43
|
+
toolPhrase?: (toolName: string) => ToolPhrase | undefined;
|
|
44
|
+
agentLabel?: string;
|
|
45
|
+
/** Called with everything the console renderer writes, e.g. to mirror a session log. */
|
|
46
|
+
onWrite?: (text: string) => void;
|
|
47
|
+
/** Columns the live area may use (see liveWidth()); 80 if not given. */
|
|
48
|
+
width?: () => number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Columns the live area may use: short of the edge, where the Windows console wraps early
|
|
52
|
+
* (see lineBuffer.ts). Read on every use, so a resized terminal is picked up.
|
|
53
|
+
*/
|
|
54
|
+
export declare function liveWidth(columns: number | undefined): number;
|
|
55
|
+
export interface SessionModel {
|
|
56
|
+
/** The console renderer behind the session log, for writes that aren't events. */
|
|
57
|
+
renderer: ConsoleRenderer;
|
|
58
|
+
render(event: AgentEvent): void;
|
|
59
|
+
/** A line shown and passed to `onWrite` (the log), as the console would print it. */
|
|
60
|
+
writeLine(text: string, kind?: OutputKind): void;
|
|
61
|
+
/** Lines shown in the history but not passed to `onWrite` (the human's line, checkpoints, the header). */
|
|
62
|
+
note(text: string, kind?: OutputKind): void;
|
|
63
|
+
/** One blank line before what comes next (not logged), unless the history is empty or already ends on one. */
|
|
64
|
+
separate(): void;
|
|
65
|
+
startTurn(): void;
|
|
66
|
+
endTurn(): void;
|
|
67
|
+
/** Ctrl+O: folds or unfolds the tool groups. */
|
|
68
|
+
toggleExpanded(): void;
|
|
69
|
+
subscribe(listener: () => void): () => void;
|
|
70
|
+
getSnapshot(): SessionSnapshot;
|
|
71
|
+
/** The agent's text in the latest turn, plain (for /copy). */
|
|
72
|
+
lastReply(): string;
|
|
73
|
+
setContextPercent(percent: number | null): void;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The state behind the Ink views, in the look of the Claude Code CLI: the agent's words
|
|
77
|
+
* behind a `●` with their markdown rendered, consecutive tool calls folded into one summary
|
|
78
|
+
* line (unfolded with Ctrl+O into `●` calls with `⎿` results), a summary when a turn ends,
|
|
79
|
+
* and one blank line between blocks of different kinds.
|
|
80
|
+
*
|
|
81
|
+
* The screen is built from the events here; the session log still comes from
|
|
82
|
+
* `createConsoleRenderer()` through `onWrite`, so it reads exactly as the plain console
|
|
83
|
+
* (and runChatTui()) would print it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createSessionModel(options?: SessionModelOptions): SessionModel;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createFriendlyToolLabel } from "../../core/toolLabels.js";
|
|
2
|
+
import { createConsoleRenderer } from "../consoleRenderer.js";
|
|
3
|
+
import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
4
|
+
import { finishedLength, renderMarkdown } from "./markdown.js";
|
|
5
|
+
import { toolGroupExpanded, toolGroupSummary } from "./toolGroup.js";
|
|
6
|
+
import * as ui from "../ui.js";
|
|
7
|
+
/**
|
|
8
|
+
* Columns the live area may use: short of the edge, where the Windows console wraps early
|
|
9
|
+
* (see lineBuffer.ts). Read on every use, so a resized terminal is picked up.
|
|
10
|
+
*/
|
|
11
|
+
export function liveWidth(columns) {
|
|
12
|
+
return Math.max(1, (columns || 80) - 4);
|
|
13
|
+
}
|
|
14
|
+
// The reply in progress keeps at most this many rows live; older ones go to the history.
|
|
15
|
+
const LIVE_ROWS = 6;
|
|
16
|
+
const BULLET = "● ";
|
|
17
|
+
const INDENT = " ";
|
|
18
|
+
const isBlank = (line) => stripAnsi(line).trim() === "";
|
|
19
|
+
/**
|
|
20
|
+
* The state behind the Ink views, in the look of the Claude Code CLI: the agent's words
|
|
21
|
+
* behind a `●` with their markdown rendered, consecutive tool calls folded into one summary
|
|
22
|
+
* line (unfolded with Ctrl+O into `●` calls with `⎿` results), a summary when a turn ends,
|
|
23
|
+
* and one blank line between blocks of different kinds.
|
|
24
|
+
*
|
|
25
|
+
* The screen is built from the events here; the session log still comes from
|
|
26
|
+
* `createConsoleRenderer()` through `onWrite`, so it reads exactly as the plain console
|
|
27
|
+
* (and runChatTui()) would print it.
|
|
28
|
+
*/
|
|
29
|
+
export function createSessionModel(options = {}) {
|
|
30
|
+
const formatAction = options.formatAction ?? createFriendlyToolLabel();
|
|
31
|
+
const width = () => Math.max(20, options.width?.() ?? 80);
|
|
32
|
+
const listeners = new Set();
|
|
33
|
+
let nextId = 0;
|
|
34
|
+
let snapshot = {
|
|
35
|
+
items: [],
|
|
36
|
+
live: "",
|
|
37
|
+
liveGap: false,
|
|
38
|
+
busy: false,
|
|
39
|
+
turnStartedAt: null,
|
|
40
|
+
activity: null,
|
|
41
|
+
subagentActivity: null,
|
|
42
|
+
expanded: true,
|
|
43
|
+
turns: 0,
|
|
44
|
+
usage: null,
|
|
45
|
+
contextPercent: null,
|
|
46
|
+
};
|
|
47
|
+
function update(changes) {
|
|
48
|
+
snapshot = { ...snapshot, ...changes };
|
|
49
|
+
for (const listener of listeners)
|
|
50
|
+
listener();
|
|
51
|
+
}
|
|
52
|
+
// The session log: the console renderer's text, never shown here.
|
|
53
|
+
const renderer = createConsoleRenderer({ formatAction, agentLabel: options.agentLabel, output: () => { }, onWrite: options.onWrite });
|
|
54
|
+
let lastKind = null;
|
|
55
|
+
// A blank line asked for by `separate()`, not yet in the history. Ink's <Static> drops a
|
|
56
|
+
// render whose only new item is a blank line (confirmed with ink-testing-library), so a
|
|
57
|
+
// separator always goes into the history together with the line after it, and until then
|
|
58
|
+
// is drawn as a margin above the live area.
|
|
59
|
+
let gapPending = false;
|
|
60
|
+
let reply = "";
|
|
61
|
+
// The agent's text since its last tool call: `segmentDone` characters of it already
|
|
62
|
+
// rendered as finished blocks, plus `rowsCommitted` rows of the block still growing
|
|
63
|
+
// (moved out of the live area when it grew past LIVE_ROWS).
|
|
64
|
+
let segment = "";
|
|
65
|
+
let segmentDone = 0;
|
|
66
|
+
let rowsCommitted = 0;
|
|
67
|
+
let segmentShown = false;
|
|
68
|
+
let group = null;
|
|
69
|
+
function lastIsBlank(items) {
|
|
70
|
+
const last = items.at(-1);
|
|
71
|
+
return !last || isBlank(last.text);
|
|
72
|
+
}
|
|
73
|
+
/** Adds a block's lines, with a blank line before it when the kind of output changes. */
|
|
74
|
+
function push(lines, kind, extra = {}) {
|
|
75
|
+
const items = [...snapshot.items];
|
|
76
|
+
if (lines.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
if ((gapPending || (lastKind !== null && kind !== lastKind)) && !lastIsBlank(items))
|
|
79
|
+
items.push({ id: nextId++, text: "" });
|
|
80
|
+
gapPending = false;
|
|
81
|
+
lines.forEach((text, i) => items.push({ id: nextId++, text, kind, ...(i === 0 ? extra : {}) }));
|
|
82
|
+
lastKind = kind;
|
|
83
|
+
snapshot = { ...snapshot, items };
|
|
84
|
+
}
|
|
85
|
+
function mdWidth() {
|
|
86
|
+
return width() - INDENT.length;
|
|
87
|
+
}
|
|
88
|
+
/** The reply's rows behind its bullet (the segment's first row) or indent (the rest). */
|
|
89
|
+
function decorate(rows) {
|
|
90
|
+
return rows.map((row) => {
|
|
91
|
+
if (isBlank(row))
|
|
92
|
+
return "";
|
|
93
|
+
const prefix = segmentShown ? INDENT : BULLET;
|
|
94
|
+
segmentShown = true;
|
|
95
|
+
return prefix + row;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Renders the finished part of the reply into the history; `final` flushes it all. */
|
|
99
|
+
function flushSegment(final) {
|
|
100
|
+
const done = final ? segment.length : finishedLength(segment);
|
|
101
|
+
if (done > segmentDone) {
|
|
102
|
+
const rows = renderMarkdown(segment.slice(segmentDone, done), mdWidth()).slice(rowsCommitted);
|
|
103
|
+
// A new block of the same reply is separated by a blank line; the rest of a block
|
|
104
|
+
// whose first rows are already in the history continues right under them.
|
|
105
|
+
const lead = segmentShown && rowsCommitted === 0 && rows.length > 0 ? [""] : [];
|
|
106
|
+
if (rows.some((row) => !isBlank(row)))
|
|
107
|
+
push([...lead, ...decorate(rows)], "agent");
|
|
108
|
+
segmentDone = done;
|
|
109
|
+
rowsCommitted = 0;
|
|
110
|
+
}
|
|
111
|
+
if (final) {
|
|
112
|
+
segment = "";
|
|
113
|
+
segmentDone = 0;
|
|
114
|
+
rowsCommitted = 0;
|
|
115
|
+
segmentShown = false;
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
const rest = segment.slice(segmentDone);
|
|
119
|
+
if (!rest.trim())
|
|
120
|
+
return [];
|
|
121
|
+
const rows = renderMarkdown(rest, mdWidth());
|
|
122
|
+
const overflow = rows.length - rowsCommitted - LIVE_ROWS;
|
|
123
|
+
if (overflow > 0) {
|
|
124
|
+
const lead = segmentShown && rowsCommitted === 0 ? [""] : [];
|
|
125
|
+
push([...lead, ...decorate(rows.slice(rowsCommitted, rowsCommitted + overflow))], "agent");
|
|
126
|
+
rowsCommitted += overflow;
|
|
127
|
+
}
|
|
128
|
+
// Live rows get their bullet/indent without marking the segment as shown yet.
|
|
129
|
+
const shown = segmentShown;
|
|
130
|
+
const live = decorate(rows.slice(rowsCommitted));
|
|
131
|
+
segmentShown = shown;
|
|
132
|
+
return live;
|
|
133
|
+
}
|
|
134
|
+
function closeGroup() {
|
|
135
|
+
if (!group)
|
|
136
|
+
return;
|
|
137
|
+
const calls = group;
|
|
138
|
+
group = null;
|
|
139
|
+
push([groupLine(calls)], "action", { expanded: toolGroupExpanded(calls, width()) });
|
|
140
|
+
}
|
|
141
|
+
function groupLine(calls) {
|
|
142
|
+
return INDENT + ui.dim(fitWidth(toolGroupSummary(calls, options.toolPhrase), width() - INDENT.length));
|
|
143
|
+
}
|
|
144
|
+
/** Ends whatever is open (the reply in progress, a tool group) into the history. */
|
|
145
|
+
function closeAll() {
|
|
146
|
+
flushSegment(true);
|
|
147
|
+
closeGroup();
|
|
148
|
+
}
|
|
149
|
+
/** Recomputes the live area from what is open. */
|
|
150
|
+
function refreshLive(liveRows = []) {
|
|
151
|
+
let rows = liveRows;
|
|
152
|
+
let kind = liveRows.length > 0 ? "agent" : null;
|
|
153
|
+
if (group) {
|
|
154
|
+
rows = snapshot.expanded ? toolGroupExpanded(group, width()) : [groupLine(group)];
|
|
155
|
+
kind = "action";
|
|
156
|
+
}
|
|
157
|
+
const live = rows.join("\n");
|
|
158
|
+
const liveGap = gapPending || (kind !== null && lastKind !== null && kind !== lastKind && !lastIsBlank(snapshot.items));
|
|
159
|
+
update({ live, liveGap });
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
renderer,
|
|
163
|
+
render(event) {
|
|
164
|
+
renderer.render(event);
|
|
165
|
+
switch (event.type) {
|
|
166
|
+
case "text": {
|
|
167
|
+
reply += event.text;
|
|
168
|
+
closeGroup();
|
|
169
|
+
segment += event.text;
|
|
170
|
+
refreshLive(flushSegment(false));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case "action": {
|
|
174
|
+
flushSegment(true);
|
|
175
|
+
group ??= [];
|
|
176
|
+
group.push({ id: event.toolUseId, label: formatAction(event.toolName, event.input), toolName: event.toolName, result: null });
|
|
177
|
+
snapshot = { ...snapshot, activity: formatAction(event.toolName, event.input), subagentActivity: null };
|
|
178
|
+
refreshLive();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
case "tool-result": {
|
|
182
|
+
const call = group?.find((c) => c.id === event.toolUseId);
|
|
183
|
+
if (call)
|
|
184
|
+
call.result = { isError: event.isError, text: event.text };
|
|
185
|
+
refreshLive();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
case "subagent-action": {
|
|
189
|
+
const label = formatAction(event.toolName, event.input);
|
|
190
|
+
const parent = group?.find((c) => c.id !== undefined && c.id === event.parentToolUseId);
|
|
191
|
+
if (parent)
|
|
192
|
+
parent.children = [...(parent.children ?? []), label];
|
|
193
|
+
snapshot = { ...snapshot, subagentActivity: label };
|
|
194
|
+
refreshLive();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case "mcp-error":
|
|
198
|
+
closeAll();
|
|
199
|
+
push([ui.warn(`Some MCP servers failed to connect: ${event.failedServers.join(", ")}`)], "notice");
|
|
200
|
+
refreshLive();
|
|
201
|
+
return;
|
|
202
|
+
case "info":
|
|
203
|
+
closeAll();
|
|
204
|
+
push([(event.level === "warning" ? ui.warn : ui.dim)(`(${event.text})`)], "notice");
|
|
205
|
+
refreshLive();
|
|
206
|
+
return;
|
|
207
|
+
case "turn-end":
|
|
208
|
+
closeAll();
|
|
209
|
+
if (event.failed && event.errorText)
|
|
210
|
+
push([ui.error(event.errorText)], "error");
|
|
211
|
+
snapshot = { ...snapshot, turns: snapshot.turns + 1, usage: event.usage ?? snapshot.usage };
|
|
212
|
+
refreshLive();
|
|
213
|
+
return;
|
|
214
|
+
case "prompt-suggestion":
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
writeLine(text, kind = "notice") {
|
|
219
|
+
closeAll();
|
|
220
|
+
renderer.writeLine(text);
|
|
221
|
+
push(text.split("\n"), kind);
|
|
222
|
+
refreshLive();
|
|
223
|
+
},
|
|
224
|
+
note(text, kind = "note") {
|
|
225
|
+
closeAll();
|
|
226
|
+
push(text.split("\n"), kind);
|
|
227
|
+
refreshLive();
|
|
228
|
+
},
|
|
229
|
+
separate() {
|
|
230
|
+
if (!lastIsBlank(snapshot.items))
|
|
231
|
+
gapPending = true;
|
|
232
|
+
refreshLive();
|
|
233
|
+
},
|
|
234
|
+
startTurn() {
|
|
235
|
+
reply = "";
|
|
236
|
+
renderer.startTurn();
|
|
237
|
+
update({ busy: true, turnStartedAt: Date.now(), activity: null, subagentActivity: null });
|
|
238
|
+
},
|
|
239
|
+
endTurn() {
|
|
240
|
+
closeAll();
|
|
241
|
+
renderer.endLine();
|
|
242
|
+
const started = snapshot.turnStartedAt;
|
|
243
|
+
if (started !== null) {
|
|
244
|
+
const seconds = Math.max(1, Math.round((Date.now() - started) / 1000));
|
|
245
|
+
push([ui.dim(`✻ Worked for ${seconds}s`)], "note");
|
|
246
|
+
}
|
|
247
|
+
snapshot = { ...snapshot, busy: false, turnStartedAt: null, activity: null, subagentActivity: null };
|
|
248
|
+
refreshLive();
|
|
249
|
+
},
|
|
250
|
+
toggleExpanded() {
|
|
251
|
+
snapshot = { ...snapshot, expanded: !snapshot.expanded };
|
|
252
|
+
refreshLive();
|
|
253
|
+
},
|
|
254
|
+
subscribe(listener) {
|
|
255
|
+
listeners.add(listener);
|
|
256
|
+
return () => listeners.delete(listener);
|
|
257
|
+
},
|
|
258
|
+
getSnapshot: () => snapshot,
|
|
259
|
+
lastReply: () => reply.trim(),
|
|
260
|
+
setContextPercent: (percent) => update({ contextPercent: percent }),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DOMElement } from "ink";
|
|
2
|
+
/** Where the terminal's cursor goes: `column` columns right of where `node` starts. */
|
|
3
|
+
export interface CursorTarget {
|
|
4
|
+
node: DOMElement;
|
|
5
|
+
column: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CursorController {
|
|
8
|
+
/** The input to put the cursor in, or null to hide it (no input on screen). */
|
|
9
|
+
setTarget(target: CursorTarget | null): void;
|
|
10
|
+
/** The stream Ink must write to (see createCursorController()). */
|
|
11
|
+
stream: NodeJS.WriteStream;
|
|
12
|
+
}
|
|
13
|
+
/** Where `node` sits in the frame, from the laid-out Yoga tree (0-based column and row). */
|
|
14
|
+
export declare function framePosition(node: DOMElement): {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The terminal's own cursor for the full-screen chat, so it looks like the terminal's (a
|
|
20
|
+
* bar in Windows Terminal's default profile, as in Claude Code) instead of a drawn block.
|
|
21
|
+
*
|
|
22
|
+
* Ink's `useCursor` doesn't fit a frame as tall as the terminal: that redraw path moves the
|
|
23
|
+
* cursor only when its position changes, and one row off. A full-screen frame always starts
|
|
24
|
+
* at the top-left corner, so after Ink writes, the cursor is moved to its absolute position.
|
|
25
|
+
* Ink writes through `stream`, which hides the cursor before every write: otherwise, between
|
|
26
|
+
* a frame (which leaves the cursor at its end) and the move, the terminal draws the cursor
|
|
27
|
+
* at the bottom too, and with the spinner redrawing ~12 times a second it shows in two
|
|
28
|
+
* places at once (seen on Windows Terminal).
|
|
29
|
+
*/
|
|
30
|
+
export declare function createCursorController(output: NodeJS.WriteStream): CursorController;
|
|
31
|
+
/** Set by the full-screen chat; an input without it draws its own block cursor. */
|
|
32
|
+
export declare const CursorContext: import("react").Context<CursorController | null>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
const HIDE = "\x1b[?25l";
|
|
3
|
+
const SHOW = "\x1b[?25h";
|
|
4
|
+
/** Where `node` sits in the frame, from the laid-out Yoga tree (0-based column and row). */
|
|
5
|
+
export function framePosition(node) {
|
|
6
|
+
let x = 0;
|
|
7
|
+
let y = 0;
|
|
8
|
+
for (let current = node; current; current = current.parentNode) {
|
|
9
|
+
x += current.yogaNode?.getComputedLeft() ?? 0;
|
|
10
|
+
y += current.yogaNode?.getComputedTop() ?? 0;
|
|
11
|
+
}
|
|
12
|
+
return { x: Math.round(x), y: Math.round(y) };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The terminal's own cursor for the full-screen chat, so it looks like the terminal's (a
|
|
16
|
+
* bar in Windows Terminal's default profile, as in Claude Code) instead of a drawn block.
|
|
17
|
+
*
|
|
18
|
+
* Ink's `useCursor` doesn't fit a frame as tall as the terminal: that redraw path moves the
|
|
19
|
+
* cursor only when its position changes, and one row off. A full-screen frame always starts
|
|
20
|
+
* at the top-left corner, so after Ink writes, the cursor is moved to its absolute position.
|
|
21
|
+
* Ink writes through `stream`, which hides the cursor before every write: otherwise, between
|
|
22
|
+
* a frame (which leaves the cursor at its end) and the move, the terminal draws the cursor
|
|
23
|
+
* at the bottom too, and with the spinner redrawing ~12 times a second it shows in two
|
|
24
|
+
* places at once (seen on Windows Terminal).
|
|
25
|
+
*/
|
|
26
|
+
export function createCursorController(output) {
|
|
27
|
+
let target = null;
|
|
28
|
+
let scheduled = false;
|
|
29
|
+
function place() {
|
|
30
|
+
scheduled = false;
|
|
31
|
+
if (!target)
|
|
32
|
+
return; // stays hidden
|
|
33
|
+
const { x, y } = framePosition(target.node);
|
|
34
|
+
output.write(`\x1b[${y + 1};${x + target.column + 1}H${SHOW}`);
|
|
35
|
+
}
|
|
36
|
+
function schedule() {
|
|
37
|
+
if (scheduled)
|
|
38
|
+
return;
|
|
39
|
+
scheduled = true;
|
|
40
|
+
// After the rest of Ink's writes for this frame, which are synchronous.
|
|
41
|
+
queueMicrotask(place);
|
|
42
|
+
}
|
|
43
|
+
const write = (chunk, ...rest) => {
|
|
44
|
+
const ok = output.write(typeof chunk === "string" ? HIDE + chunk : chunk, ...rest);
|
|
45
|
+
schedule();
|
|
46
|
+
return ok;
|
|
47
|
+
};
|
|
48
|
+
// Everything but write goes to the real stream (columns, rows, resize events, isTTY...).
|
|
49
|
+
const stream = new Proxy(output, {
|
|
50
|
+
get(real, property) {
|
|
51
|
+
if (property === "write")
|
|
52
|
+
return write;
|
|
53
|
+
const value = Reflect.get(real, property, real);
|
|
54
|
+
return typeof value === "function" ? value.bind(real) : value;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
setTarget(next) {
|
|
59
|
+
target = next;
|
|
60
|
+
if (!next)
|
|
61
|
+
output.write(HIDE);
|
|
62
|
+
schedule();
|
|
63
|
+
},
|
|
64
|
+
stream,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Set by the full-screen chat; an input without it draws its own block cursor. */
|
|
68
|
+
export const CursorContext = createContext(null);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** True when `input` is only focus reports, which must never reach an input as text. */
|
|
2
|
+
export declare function isFocusReport(input: string): boolean;
|
|
3
|
+
/** The focus state the reports in `input` leave: true (focused), false, or null if none. */
|
|
4
|
+
export declare function focusFromReport(input: string): boolean | null;
|
|
5
|
+
export interface TerminalStatus {
|
|
6
|
+
/** Title and focus reporting on; call once. */
|
|
7
|
+
start(): void;
|
|
8
|
+
turnStarted(): void;
|
|
9
|
+
turnEnded(): void;
|
|
10
|
+
setFocused(focused: boolean): void;
|
|
11
|
+
/** Everything back as it was (title cleared, taskbar cleared, focus reporting off). */
|
|
12
|
+
stop(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Shows the chat's state outside it, through sequences Windows Terminal understands: the
|
|
16
|
+
* tab title ("✻ <name> — working…" during a turn), the taskbar button (indeterminate
|
|
17
|
+
* progress during a turn) and, when a turn ends while the window isn't focused, a paused
|
|
18
|
+
* (yellow) taskbar state until it's focused again.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createTerminalStatus(write: (text: string) => void, name: string): TerminalStatus;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Focus reporting: the terminal sends ESC [ I / ESC [ O when the window gains/loses focus.
|
|
2
|
+
const FOCUS_ON = "\x1b[?1004h";
|
|
3
|
+
const FOCUS_OFF = "\x1b[?1004l";
|
|
4
|
+
// eslint-disable-next-line no-control-regex -- \x1b is the ESC byte the report may start with
|
|
5
|
+
const FOCUS_REPORT = /^(?:\x1b?\[[IO])+$/;
|
|
6
|
+
/** OSC 0: the window/tab title. */
|
|
7
|
+
const title = (text) => `\x1b]0;${text}\x07`;
|
|
8
|
+
// OSC 9;4: Windows Terminal's taskbar progress: 0 clears it, 3 is indeterminate, 4 is paused
|
|
9
|
+
// (yellow), used here as "needs attention".
|
|
10
|
+
const PROGRESS_CLEAR = "\x1b]9;4;0;0\x07";
|
|
11
|
+
const PROGRESS_BUSY = "\x1b]9;4;3;0\x07";
|
|
12
|
+
const PROGRESS_ATTENTION = "\x1b]9;4;4;100\x07";
|
|
13
|
+
/** True when `input` is only focus reports, which must never reach an input as text. */
|
|
14
|
+
export function isFocusReport(input) {
|
|
15
|
+
return FOCUS_REPORT.test(input);
|
|
16
|
+
}
|
|
17
|
+
/** The focus state the reports in `input` leave: true (focused), false, or null if none. */
|
|
18
|
+
export function focusFromReport(input) {
|
|
19
|
+
if (!isFocusReport(input))
|
|
20
|
+
return null;
|
|
21
|
+
return input.endsWith("[I");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Shows the chat's state outside it, through sequences Windows Terminal understands: the
|
|
25
|
+
* tab title ("✻ <name> — working…" during a turn), the taskbar button (indeterminate
|
|
26
|
+
* progress during a turn) and, when a turn ends while the window isn't focused, a paused
|
|
27
|
+
* (yellow) taskbar state until it's focused again.
|
|
28
|
+
*/
|
|
29
|
+
export function createTerminalStatus(write, name) {
|
|
30
|
+
let focused = true;
|
|
31
|
+
let needsAttention = false;
|
|
32
|
+
return {
|
|
33
|
+
start() {
|
|
34
|
+
write(FOCUS_ON + title(name));
|
|
35
|
+
},
|
|
36
|
+
turnStarted() {
|
|
37
|
+
needsAttention = false;
|
|
38
|
+
write(title(`✻ ${name} — working…`) + PROGRESS_BUSY);
|
|
39
|
+
},
|
|
40
|
+
turnEnded() {
|
|
41
|
+
needsAttention = !focused;
|
|
42
|
+
write(title(name) + (needsAttention ? PROGRESS_ATTENTION : PROGRESS_CLEAR));
|
|
43
|
+
},
|
|
44
|
+
setFocused(next) {
|
|
45
|
+
focused = next;
|
|
46
|
+
if (focused && needsAttention) {
|
|
47
|
+
needsAttention = false;
|
|
48
|
+
write(PROGRESS_CLEAR);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
stop() {
|
|
52
|
+
write(FOCUS_OFF + PROGRESS_CLEAR + title(""));
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ToolCall {
|
|
2
|
+
id?: string;
|
|
3
|
+
toolName: string;
|
|
4
|
+
/** The friendly label (see toolLabels.ts), shown when the group is unfolded. */
|
|
5
|
+
label: string;
|
|
6
|
+
result: {
|
|
7
|
+
isError: boolean;
|
|
8
|
+
text: string;
|
|
9
|
+
} | null;
|
|
10
|
+
/** For a call that runs a subagent: the labels of the tool calls it made, in order. */
|
|
11
|
+
children?: string[];
|
|
12
|
+
}
|
|
13
|
+
/** How a tool counts in a group's summary: the phrase for one call and for several, "{n}" standing for the count. */
|
|
14
|
+
export type ToolPhrase = [one: string, many: string];
|
|
15
|
+
/**
|
|
16
|
+
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
17
|
+
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
18
|
+
* appear; tools with no phrase of their own (MCP tools, the consumer's) count as "tools",
|
|
19
|
+
* unless `phraseFor` gives one.
|
|
20
|
+
*/
|
|
21
|
+
export declare function toolGroupSummary(calls: readonly ToolCall[], phraseFor?: (toolName: string) => ToolPhrase | undefined): string;
|
|
22
|
+
/**
|
|
23
|
+
* One line for a tool's result: its first line (in red if it failed) and how many more it
|
|
24
|
+
* had. `markdown`: the first line is rendered (a subagent's answer), so no "**" or
|
|
25
|
+
* backticks show.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resultSummary(result: {
|
|
28
|
+
isError: boolean;
|
|
29
|
+
text: string;
|
|
30
|
+
}, markdown?: boolean): string;
|
|
31
|
+
/**
|
|
32
|
+
* The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
|
|
33
|
+
* latest few, dim) and the result in one line (a subagent's answer with its markdown
|
|
34
|
+
* rendered), or "…" while it runs.
|
|
35
|
+
*/
|
|
36
|
+
export declare function toolGroupExpanded(calls: readonly ToolCall[], width: number): string[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { fitWidth } from "./lineBuffer.js";
|
|
2
|
+
import { renderMarkdown } from "./markdown.js";
|
|
3
|
+
import * as ui from "../ui.js";
|
|
4
|
+
// A subagent can make many calls; the latest ones are shown, the rest counted.
|
|
5
|
+
const MAX_CHILDREN = 5;
|
|
6
|
+
// Tools whose result is a subagent's answer, in markdown: its first line is rendered.
|
|
7
|
+
const SUBAGENT_TOOLS = new Set(["Agent", "Task"]);
|
|
8
|
+
const BUILT_IN = {
|
|
9
|
+
Read: ["read {n} file", "read {n} files"],
|
|
10
|
+
Write: ["wrote {n} file", "wrote {n} files"],
|
|
11
|
+
Edit: ["edited {n} file", "edited {n} files"],
|
|
12
|
+
Glob: ["listed files", "listed files {n} times"],
|
|
13
|
+
Grep: ["searched {n} time", "searched {n} times"],
|
|
14
|
+
Bash: ["ran {n} shell command", "ran {n} shell commands"],
|
|
15
|
+
WebSearch: ["searched the web", "searched the web {n} times"],
|
|
16
|
+
WebFetch: ["fetched {n} page", "fetched {n} pages"],
|
|
17
|
+
Skill: ["used {n} skill", "used {n} skills"],
|
|
18
|
+
Agent: ["ran {n} subagent", "ran {n} subagents"],
|
|
19
|
+
Task: ["ran {n} subagent", "ran {n} subagents"],
|
|
20
|
+
};
|
|
21
|
+
const OTHER = ["used {n} tool", "used {n} tools"];
|
|
22
|
+
/**
|
|
23
|
+
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
24
|
+
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
25
|
+
* appear; tools with no phrase of their own (MCP tools, the consumer's) count as "tools",
|
|
26
|
+
* unless `phraseFor` gives one.
|
|
27
|
+
*/
|
|
28
|
+
export function toolGroupSummary(calls, phraseFor) {
|
|
29
|
+
// Counted by the phrase's text: a consumer's phraseFor() may return a new array each call.
|
|
30
|
+
const counts = new Map();
|
|
31
|
+
for (const call of calls) {
|
|
32
|
+
const phrase = phraseFor?.(call.toolName) ?? BUILT_IN[call.toolName] ?? OTHER;
|
|
33
|
+
const key = phrase.join("|");
|
|
34
|
+
const entry = counts.get(key) ?? { phrase, n: 0 };
|
|
35
|
+
entry.n++;
|
|
36
|
+
counts.set(key, entry);
|
|
37
|
+
}
|
|
38
|
+
const text = [...counts.values()].map(({ phrase: [one, many], n }) => (n === 1 ? one : many).replace("{n}", String(n))).join(", ");
|
|
39
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One line for a tool's result: its first line (in red if it failed) and how many more it
|
|
43
|
+
* had. `markdown`: the first line is rendered (a subagent's answer), so no "**" or
|
|
44
|
+
* backticks show.
|
|
45
|
+
*/
|
|
46
|
+
export function resultSummary(result, markdown = false) {
|
|
47
|
+
const lines = result.text.split("\n").filter((line) => line.trim() !== "");
|
|
48
|
+
if (lines.length === 0)
|
|
49
|
+
return result.isError ? ui.error("error") : ui.dim("(no output)");
|
|
50
|
+
const more = lines.length > 1 ? ui.dim(` (+${lines.length - 1} ${lines.length === 2 ? "line" : "lines"})`) : "";
|
|
51
|
+
const first = lines[0].trim();
|
|
52
|
+
if (result.isError)
|
|
53
|
+
return ui.error(first) + more;
|
|
54
|
+
// Rendered wide, as one row: the summary line is cut to the room left afterwards.
|
|
55
|
+
return (markdown ? (renderMarkdown(first, 1000)[0] ?? first) : first) + more;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
|
|
59
|
+
* latest few, dim) and the result in one line (a subagent's answer with its markdown
|
|
60
|
+
* rendered), or "…" while it runs.
|
|
61
|
+
*/
|
|
62
|
+
export function toolGroupExpanded(calls, width) {
|
|
63
|
+
return calls.flatMap((call) => {
|
|
64
|
+
const children = call.children ?? [];
|
|
65
|
+
const shown = children.slice(-MAX_CHILDREN);
|
|
66
|
+
const result = call.result ? fitWidth(resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName)), width - 5) : ui.dim("…");
|
|
67
|
+
const under = [
|
|
68
|
+
...(children.length > shown.length ? [ui.dim(`… ${children.length - shown.length} earlier`)] : []),
|
|
69
|
+
...shown.map((label) => fitWidth(ui.dim(`· ${label}`), width - 5)),
|
|
70
|
+
result,
|
|
71
|
+
];
|
|
72
|
+
return [
|
|
73
|
+
`${ui.toolBullet("●")} ${fitWidth(call.label, width - 2)}`,
|
|
74
|
+
...under.map((line, i) => (line === "" ? "" : ` ${i === 0 ? ui.dim("⎿") : " "} ${line}`)),
|
|
75
|
+
];
|
|
76
|
+
});
|
|
77
|
+
}
|