@falkenslab/agent-kit 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/dist/core/agentSpec.d.ts +24 -2
- package/dist/core/hooks/planGate.d.ts +23 -0
- package/dist/core/hooks/planGate.js +52 -0
- package/dist/core/messages/de.js +3 -1
- package/dist/core/messages/en.d.ts +2 -0
- package/dist/core/messages/en.js +2 -0
- package/dist/core/messages/es.js +3 -1
- package/dist/core/messages/fr.js +3 -1
- package/dist/core/runs.js +2 -0
- package/dist/core/session.d.ts +28 -10
- package/dist/core/session.js +60 -5
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/tui/chatTui.d.ts +8 -0
- package/dist/tui/chatTui.js +11 -3
- package/dist/tui/ink/SessionView.d.ts +6 -2
- package/dist/tui/ink/SessionView.js +33 -12
- package/dist/tui/ink/markdown.d.ts +5 -0
- package/dist/tui/ink/markdown.js +7 -0
- package/dist/tui/ink/progressView.d.ts +5 -1
- package/dist/tui/ink/runChatInk.d.ts +20 -4
- package/dist/tui/ink/runChatInk.js +19 -3
- package/dist/tui/ink/sessionModel.d.ts +7 -3
- package/dist/tui/ink/sessionModel.js +22 -4
- package/dist/tui/ink/toolGroup.d.ts +23 -2
- package/dist/tui/ink/toolGroup.js +17 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
<h1>
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-readme-dark.svg">
|
|
4
|
+
<img src="docs/assets/logo-readme.svg" alt="agent-kit" height="64">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
2
7
|
|
|
3
8
|
[](https://www.npmjs.com/package/@falkenslab/agent-kit)
|
|
4
9
|
[](https://www.npmjs.com/package/@falkenslab/agent-kit)
|
|
5
10
|
[](https://www.npmjs.com/package/@falkenslab/agent-kit)
|
|
6
11
|
[](https://nodejs.org/)
|
|
7
12
|
[](LICENSE)
|
|
8
|
-
[](https://falkenslab.github.io/agent-kit/)
|
|
9
14
|
|
|
10
15
|
A foundation for building AI agents on top of the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It isn't an agent itself: it solves once what every agent needs (human oversight, safety, memory and a good terminal interface), so each concrete agent only has to write what belongs to its own domain: what it knows how to do, with which tools, and how it talks.
|
|
11
16
|
|
|
12
17
|
## What an agent built with it can do
|
|
13
18
|
|
|
14
|
-
- **Work at different levels of oversight.** Autonomous, guided (it asks for permission before anything that can't be undone or that other people will see) or step by step (it asks before every action). Within the same conversation you can switch
|
|
19
|
+
- **Work at different levels of oversight.** Autonomous, guided (it asks for permission before anything that can't be undone or that other people will see) or step by step (it asks before every action), and a plan mode where it only reads and plans until you let it act. Within the same conversation you can switch among guided, step by step and plan.
|
|
15
20
|
- **Ask a person for help.** It asks for approvals, or stops so someone can do something by hand, such as signing in to a website. It can be answered from the keyboard or from another program driving it, for example a desktop app.
|
|
16
21
|
- **Remember between sessions.** It keeps its own knowledge base, a small wiki of notes that grows with every session, and keeps the original documents you give it or that it downloads untouched. Each conversation stays in the project's folder, so you can pick it up again with `--continue` (the latest one) or `/resume` (any of them).
|
|
17
22
|
- **Speak your language.** Its interface comes in English, Spanish, French or German, the system's by default or the one you ask for with `--language=fr`, and it answers in that language unless you write to it in another one.
|
|
@@ -20,7 +25,7 @@ A foundation for building AI agents on top of the [Claude Agent SDK](https://www
|
|
|
20
25
|
- **Use external tools.** MCP servers of its own or of the project, and skills and commands organized in plugins.
|
|
21
26
|
- **Leave a trail.** It records every action and the whole conversation, with secrets hidden.
|
|
22
27
|
- **Look the way you want.** The terminal's colors come from a theme by roles (the agent's replies, tool results, the focused option, borders…): pass only the ones you want to change, as a color name, a hex or a function.
|
|
23
|
-
- **Chat in a terminal the way Claude Code does.** Full screen, with the reply written live and its markdown already formatted, the tools it uses
|
|
28
|
+
- **Chat in a terminal the way Claude Code does.** Full screen, with the reply written live and its markdown already formatted, the tools it uses shown in as much detail as the agent chooses (each call with its result, the calls alone, or one line per group; Ctrl+O unfolds them), panels for approvals, history and search, a suggestion for the next message, pasted blocks, file mentions with `@`, mouse selection (right-click to copy), and the agent's status in the tab and in the Windows taskbar.
|
|
24
29
|
- **Also work without a terminal.** The same foundation runs inside a desktop app or a server, which bring their own interface.
|
|
25
30
|
|
|
26
31
|
## Examples
|
|
@@ -72,7 +77,7 @@ const config: BaseSessionConfig = { mode: "guided", projectDir: process.cwd() };
|
|
|
72
77
|
await runChatInk((run) => buildSessionOptions(config, run.dir, spec, { run }), {
|
|
73
78
|
runsDir: path.resolve(".run"),
|
|
74
79
|
header: { title: "My agent" },
|
|
75
|
-
mode: config.mode, // Shift+Tab
|
|
80
|
+
mode: config.mode, // Shift+Tab cycles through guided, step by step and plan
|
|
76
81
|
fullscreen: true,
|
|
77
82
|
theme: { toolResult: "gray", selection: "#ffb86c" }, // only the colors you want to change
|
|
78
83
|
});
|
package/dist/core/agentSpec.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { AgentDefinition as SdkSubagentDefinition, McpServerConfig, Setting
|
|
|
3
3
|
* The human-supervision spectrum every agent built on this kit shares, independent of
|
|
4
4
|
* domain: "interactive" pauses before every single action, "guided" only pauses before a
|
|
5
5
|
* hard-to-undo/visible-to-others action, "autonomous" has no human-in-the-loop channel at
|
|
6
|
-
* all
|
|
6
|
+
* all, and "plan" only reads and plans: nothing is changed until the human leaves it (see
|
|
7
|
+
* hooks/planGate.ts). See session.ts's buildSessionOptions() for exactly what each mode changes.
|
|
7
8
|
*
|
|
8
9
|
* Deliberately orthogonal to whether the session is a one-shot run or a multi-turn chat
|
|
9
10
|
* (that's a matter of which entry point the caller uses — a single `query()` call vs.
|
|
@@ -12,7 +13,7 @@ import type { AgentDefinition as SdkSubagentDefinition, McpServerConfig, Setting
|
|
|
12
13
|
* different system-prompt template) should track that as its own domain field on its
|
|
13
14
|
* config type, not conflate it with `Mode`.
|
|
14
15
|
*/
|
|
15
|
-
export type Mode = "interactive" | "guided" | "autonomous";
|
|
16
|
+
export type Mode = "interactive" | "guided" | "autonomous" | "plan";
|
|
16
17
|
/**
|
|
17
18
|
* The minimum a config object needs to drive `buildSessionOptions()` — a concrete agent's
|
|
18
19
|
* own config type (e.g. a `Config`) extends this with whatever domain fields
|
|
@@ -159,4 +160,25 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
|
|
|
159
160
|
checkpointLines: string[];
|
|
160
161
|
checkpointQuestion?: string;
|
|
161
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* What the agent may still do in "plan" mode, where it only reads and plans (see
|
|
165
|
+
* hooks/planGate.ts). Without it, the agent can read, search, ask a human and delegate,
|
|
166
|
+
* writes nothing and presents its plan in its reply.
|
|
167
|
+
*/
|
|
168
|
+
planMode?: PlanModeSpec<TConfig>;
|
|
169
|
+
}
|
|
170
|
+
/** The domain's part of "plan" mode: which files hold the plan and which of its own tools only read. */
|
|
171
|
+
export interface PlanModeSpec<TConfig extends BaseSessionConfig = BaseSessionConfig> {
|
|
172
|
+
/**
|
|
173
|
+
* Whether `filePath` (absolute) is a plan file Write/Edit may touch in plan mode, e.g.
|
|
174
|
+
* `drafts/<slug>/plan.md`. It must also be writable under the file scope (`knowledgeDir`
|
|
175
|
+
* or `extraWritableDirs`), which still applies.
|
|
176
|
+
*/
|
|
177
|
+
isPlanFile?(filePath: string, config: TConfig): boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Whether one of the agent's own MCP tools (`mcp__<server>__<tool>`) only reads, so plan
|
|
180
|
+
* mode lets it run. Every MCP tool it doesn't vouch for is denied there: a forgotten tool
|
|
181
|
+
* can't change anything.
|
|
182
|
+
*/
|
|
183
|
+
isReadOnlyTool?(toolName: string, input: Record<string, unknown>): boolean;
|
|
162
184
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
/** What plan mode lets through besides reading: see createPlanGate(). */
|
|
3
|
+
export interface PlanScope {
|
|
4
|
+
/** Base for relative paths: the session's cwd. */
|
|
5
|
+
projectDir: string;
|
|
6
|
+
/** Whether Write/Edit may touch this file (absolute) in plan mode. */
|
|
7
|
+
isPlanFile?(filePath: string): boolean;
|
|
8
|
+
/** Whether one of the agent's own MCP tools only reads. */
|
|
9
|
+
isReadOnlyTool?(toolName: string, input: Record<string, unknown>): boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The reason to deny `toolName` with `input` in plan mode, or `undefined` to let it through.
|
|
13
|
+
* Anything not known to only read is denied, so a tool nobody thought of can't change
|
|
14
|
+
* anything while the human reviews the plan.
|
|
15
|
+
*/
|
|
16
|
+
export declare function checkPlanScope(scope: PlanScope, toolName: string, input: Record<string, unknown>): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* PreToolUse hook for "plan" mode: the agent only reads and plans (see checkPlanScope()).
|
|
19
|
+
* Registered for every session that can be in plan mode and deciding only while `isActive()`
|
|
20
|
+
* says it is; otherwise it gives no decision, so the call goes on as if it weren't there.
|
|
21
|
+
* It's the kit's own gate, not the SDK's `permissionMode: "plan"` (ADR-023).
|
|
22
|
+
*/
|
|
23
|
+
export declare function createPlanGate(scope: PlanScope, isActive: () => boolean): HookCallback;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** Built-in tools that only read, search, load a skill or delegate (a subagent's own calls come through this gate too). */
|
|
3
|
+
const READING_TOOLS = new Set(["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Skill", "Agent"]);
|
|
4
|
+
/** The kit's own MCP tools that only ask a human: approvals and manual intervention. */
|
|
5
|
+
const ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__"];
|
|
6
|
+
/** Why a call is denied in plan mode, and what the model should do instead. */
|
|
7
|
+
const denial = "Plan mode: nothing can be changed until the user leaves plan mode. Keep reading and researching, then present the plan; the user switches out of plan mode when they want it carried out.";
|
|
8
|
+
/**
|
|
9
|
+
* The reason to deny `toolName` with `input` in plan mode, or `undefined` to let it through.
|
|
10
|
+
* Anything not known to only read is denied, so a tool nobody thought of can't change
|
|
11
|
+
* anything while the human reviews the plan.
|
|
12
|
+
*/
|
|
13
|
+
export function checkPlanScope(scope, toolName, input) {
|
|
14
|
+
if (READING_TOOLS.has(toolName))
|
|
15
|
+
return undefined;
|
|
16
|
+
if (ASKING_SERVERS.some((prefix) => toolName.startsWith(prefix)))
|
|
17
|
+
return undefined;
|
|
18
|
+
if (toolName === "Write" || toolName === "Edit") {
|
|
19
|
+
const target = typeof input.file_path === "string" && input.file_path !== "" ? path.resolve(scope.projectDir, input.file_path) : undefined;
|
|
20
|
+
if (target && scope.isPlanFile?.(target))
|
|
21
|
+
return undefined;
|
|
22
|
+
return scope.isPlanFile
|
|
23
|
+
? `Plan mode: "${String(input.file_path)}" isn't a plan file, and nothing else can be written until the user leaves plan mode. Write the plan to its plan file.`
|
|
24
|
+
: denial;
|
|
25
|
+
}
|
|
26
|
+
if (toolName.startsWith("mcp__") && scope.isReadOnlyTool?.(toolName, input))
|
|
27
|
+
return undefined;
|
|
28
|
+
return denial;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* PreToolUse hook for "plan" mode: the agent only reads and plans (see checkPlanScope()).
|
|
32
|
+
* Registered for every session that can be in plan mode and deciding only while `isActive()`
|
|
33
|
+
* says it is; otherwise it gives no decision, so the call goes on as if it weren't there.
|
|
34
|
+
* It's the kit's own gate, not the SDK's `permissionMode: "plan"` (ADR-023).
|
|
35
|
+
*/
|
|
36
|
+
export function createPlanGate(scope, isActive) {
|
|
37
|
+
return async (input) => {
|
|
38
|
+
if (!isActive())
|
|
39
|
+
return {};
|
|
40
|
+
const pre = input;
|
|
41
|
+
const reason = checkPlanScope(scope, pre.tool_name, (pre.tool_input ?? {}));
|
|
42
|
+
if (!reason)
|
|
43
|
+
return {};
|
|
44
|
+
return {
|
|
45
|
+
hookSpecificOutput: {
|
|
46
|
+
hookEventName: pre.hook_event_name,
|
|
47
|
+
permissionDecision: "deny",
|
|
48
|
+
permissionDecisionReason: reason,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/core/messages/de.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const plural = (count, one, many) => (count === 1 ? one : many);
|
|
2
|
-
const MODES = { autonomous: "autonom", guided: "geführt", interactive: "interaktiv" };
|
|
2
|
+
const MODES = { autonomous: "autonom", guided: "geführt", interactive: "interaktiv", plan: "Plan" };
|
|
3
3
|
export const de = {
|
|
4
4
|
thinking: "Denkt nach…",
|
|
5
5
|
escToInterrupt: "Esc zum Unterbrechen",
|
|
@@ -35,6 +35,7 @@ export const de = {
|
|
|
35
35
|
"Esc unterbrechen · Strg+C unterbrechen oder beenden · Bild↑/Bild↓ oder Mausrad scrollen · Strg+Ende ganz nach unten",
|
|
36
36
|
"Ziehen zum Markieren, Rechtsklick zum Kopieren (Vollbild)",
|
|
37
37
|
"/resume ein früheres Gespräch fortsetzen · --continue beim Start setzt das letzte fort",
|
|
38
|
+
"/plan Planmodus ein- oder ausschalten: der Agent liest und plant nur, bis du ihn verlässt",
|
|
38
39
|
],
|
|
39
40
|
moreLines: (count) => `… (${count} weitere Zeilen)`,
|
|
40
41
|
proceed: "Möchtest du fortfahren?",
|
|
@@ -69,6 +70,7 @@ export const de = {
|
|
|
69
70
|
otherTools: ["hat {n} Werkzeug verwendet", "hat {n} Werkzeuge verwendet"],
|
|
70
71
|
noOutput: "(keine Ausgabe)",
|
|
71
72
|
error: "Fehler",
|
|
73
|
+
toolFailed: "Konnte nicht abgeschlossen werden",
|
|
72
74
|
moreResultLines: (count) => `(+${count} ${plural(count, "Zeile", "Zeilen")})`,
|
|
73
75
|
earlierCalls: (count) => `… ${count} frühere`,
|
|
74
76
|
labels: {
|
|
@@ -55,6 +55,8 @@ export interface Messages {
|
|
|
55
55
|
otherTools: ToolPhrase;
|
|
56
56
|
noOutput: string;
|
|
57
57
|
error: string;
|
|
58
|
+
/** A failed tool call when results are hidden (`toolDetail: "calls"`): no tool output, just that it failed. */
|
|
59
|
+
toolFailed: string;
|
|
58
60
|
moreResultLines(count: number): string;
|
|
59
61
|
earlierCalls(count: number): string;
|
|
60
62
|
labels: {
|
package/dist/core/messages/en.js
CHANGED
|
@@ -34,6 +34,7 @@ export const en = {
|
|
|
34
34
|
"Esc interrupt · Ctrl+C interrupt or exit · PgUp/PgDn or wheel scroll · Ctrl+End bottom",
|
|
35
35
|
"Drag to select, right-click to copy (full screen)",
|
|
36
36
|
"/resume resume an earlier conversation · --continue on the command line resumes the latest one",
|
|
37
|
+
"/plan plan mode on or off: the agent only reads and plans until you leave it",
|
|
37
38
|
],
|
|
38
39
|
moreLines: (count) => `… (${count} more lines)`,
|
|
39
40
|
proceed: "Do you want to proceed?",
|
|
@@ -68,6 +69,7 @@ export const en = {
|
|
|
68
69
|
otherTools: ["used {n} tool", "used {n} tools"],
|
|
69
70
|
noOutput: "(no output)",
|
|
70
71
|
error: "error",
|
|
72
|
+
toolFailed: "Couldn't complete it",
|
|
71
73
|
moreResultLines: (count) => `(+${count} ${plural(count, "line", "lines")})`,
|
|
72
74
|
earlierCalls: (count) => `… ${count} earlier`,
|
|
73
75
|
labels: {
|
package/dist/core/messages/es.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const plural = (count, one, many) => (count === 1 ? one : many);
|
|
2
|
-
const MODES = { autonomous: "autónomo", guided: "guiado", interactive: "interactivo" };
|
|
2
|
+
const MODES = { autonomous: "autónomo", guided: "guiado", interactive: "interactivo", plan: "plan" };
|
|
3
3
|
export const es = {
|
|
4
4
|
thinking: "Pensando…",
|
|
5
5
|
escToInterrupt: "esc para interrumpir",
|
|
@@ -35,6 +35,7 @@ export const es = {
|
|
|
35
35
|
"Esc interrumpir · Ctrl+C interrumpir o salir · RePág/AvPág o rueda desplazarse · Ctrl+Fin abajo del todo",
|
|
36
36
|
"Arrastrar para seleccionar, clic derecho para copiar (pantalla completa)",
|
|
37
37
|
"/resume retomar una conversación anterior · --continue al arrancar retoma la última",
|
|
38
|
+
"/plan activar o desactivar el modo plan: el agente solo lee y planifica hasta que salgas",
|
|
38
39
|
],
|
|
39
40
|
moreLines: (count) => `… (${count} líneas más)`,
|
|
40
41
|
proceed: "¿Quieres continuar?",
|
|
@@ -69,6 +70,7 @@ export const es = {
|
|
|
69
70
|
otherTools: ["usó {n} herramienta", "usó {n} herramientas"],
|
|
70
71
|
noOutput: "(sin salida)",
|
|
71
72
|
error: "error",
|
|
73
|
+
toolFailed: "No se pudo completar",
|
|
72
74
|
moreResultLines: (count) => `(+${count} ${plural(count, "línea", "líneas")})`,
|
|
73
75
|
earlierCalls: (count) => `… ${count} ${plural(count, "anterior", "anteriores")}`,
|
|
74
76
|
labels: {
|
package/dist/core/messages/fr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const plural = (count, one, many) => (count === 1 ? one : many);
|
|
2
|
-
const MODES = { autonomous: "autonome", guided: "guidé", interactive: "interactif" };
|
|
2
|
+
const MODES = { autonomous: "autonome", guided: "guidé", interactive: "interactif", plan: "plan" };
|
|
3
3
|
export const fr = {
|
|
4
4
|
thinking: "Réflexion…",
|
|
5
5
|
escToInterrupt: "échap pour interrompre",
|
|
@@ -35,6 +35,7 @@ export const fr = {
|
|
|
35
35
|
"Échap interrompre · Ctrl+C interrompre ou quitter · PgPréc/PgSuiv ou molette défiler · Ctrl+Fin tout en bas",
|
|
36
36
|
"Glisser pour sélectionner, clic droit pour copier (plein écran)",
|
|
37
37
|
"/resume reprendre une conversation précédente · --continue au démarrage reprend la dernière",
|
|
38
|
+
"/plan activer ou quitter le mode plan : l'agent lit et planifie seulement jusqu'à ce que vous en sortiez",
|
|
38
39
|
],
|
|
39
40
|
moreLines: (count) => `… (${count} lignes de plus)`,
|
|
40
41
|
proceed: "Voulez-vous continuer ?",
|
|
@@ -69,6 +70,7 @@ export const fr = {
|
|
|
69
70
|
otherTools: ["a utilisé {n} outil", "a utilisé {n} outils"],
|
|
70
71
|
noOutput: "(aucune sortie)",
|
|
71
72
|
error: "erreur",
|
|
73
|
+
toolFailed: "Impossible de terminer",
|
|
72
74
|
moreResultLines: (count) => `(+${count} ${plural(count, "ligne", "lignes")})`,
|
|
73
75
|
earlierCalls: (count) => `… ${count} ${plural(count, "précédent", "précédents")}`,
|
|
74
76
|
labels: {
|
package/dist/core/runs.js
CHANGED
|
@@ -151,6 +151,8 @@ export function entryText(entry) {
|
|
|
151
151
|
.map((part) => part.text ?? "")
|
|
152
152
|
.join("");
|
|
153
153
|
}
|
|
154
|
+
// The kit's own notes to the model (a plan-mode switch) go before the human's words.
|
|
155
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, "");
|
|
154
156
|
// Slash commands and the CLI's own notes arrive as tagged text, not as the human's words.
|
|
155
157
|
if (!text.trim() || /^\s*<[a-z-]+>/.test(text))
|
|
156
158
|
return null;
|
package/dist/core/session.d.ts
CHANGED
|
@@ -15,8 +15,8 @@ import { type RunFolder } from "./runs.js";
|
|
|
15
15
|
* fields on `BaseSessionConfig`.
|
|
16
16
|
*
|
|
17
17
|
* The per-mode behavioral differences (whether the approval/manual-intervention tools
|
|
18
|
-
* exist, whether the interactive step gate
|
|
19
|
-
* alone.
|
|
18
|
+
* exist, whether the interactive step gate or the plan gate decides) all fall out of
|
|
19
|
+
* `config.mode` and the session's `ModeControl` alone.
|
|
20
20
|
*
|
|
21
21
|
* `options.run` keeps the SDK's transcript of the conversation in that run folder (see
|
|
22
22
|
* runs.ts's createRunStore()) and, when it has a session already, resumes it: the chat
|
|
@@ -33,22 +33,35 @@ export declare function buildSessionOptions<TConfig extends BaseSessionConfig>(c
|
|
|
33
33
|
language: Language;
|
|
34
34
|
}>;
|
|
35
35
|
/**
|
|
36
|
-
* The supervision mode of a running session.
|
|
37
|
-
*
|
|
38
|
-
* in
|
|
39
|
-
* can't appear or vanish mid-session, so a session that
|
|
40
|
-
* and one that doesn't can't become autonomous. The
|
|
41
|
-
* built with
|
|
36
|
+
* The supervision mode of a running session. "guided", "interactive" and "plan" switch into
|
|
37
|
+
* one another: they share the same tools (the approval tool), and the step gate and the plan
|
|
38
|
+
* gate are registered in all three, deciding only in their own mode. "autonomous" has no
|
|
39
|
+
* approval tool at all, and a tool can't appear or vanish mid-session, so a session that
|
|
40
|
+
* starts autonomous stays autonomous, and one that doesn't can't become autonomous. The
|
|
41
|
+
* system prompt keeps the mode it was built with, so entering or leaving plan mode is told
|
|
42
|
+
* to the model with the next message instead (`takeNotice()`).
|
|
42
43
|
*/
|
|
43
44
|
export interface ModeControl {
|
|
44
45
|
readonly mode: Mode;
|
|
45
|
-
/** The modes this session can be in; fewer than two means it can't switch. */
|
|
46
|
+
/** The modes this session can be in, in Shift+Tab's order; fewer than two means it can't switch. */
|
|
46
47
|
readonly switchable: readonly Mode[];
|
|
47
48
|
/** Switches to `next` if this session allows it; returns whether it did. */
|
|
48
49
|
set(next: Mode): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The note telling the model it entered or left plan mode, once per change it hasn't been
|
|
52
|
+
* told about yet; `undefined` otherwise. `createInputQueue({ modeControl })` puts it before
|
|
53
|
+
* the next message; a caller with its own queue prepends it itself.
|
|
54
|
+
*/
|
|
55
|
+
takeNotice?(): string | undefined;
|
|
49
56
|
}
|
|
50
57
|
/** A `ModeControl` starting at `initial` (`buildSessionOptions()` returns one for its session). */
|
|
51
58
|
export declare function createModeControl(initial: Mode): ModeControl;
|
|
59
|
+
/**
|
|
60
|
+
* What the chats' `/plan` does: switches `control` into plan mode, or, when it's already
|
|
61
|
+
* there, back to the mode it was entered from ("guided" if it started in plan mode). Returns
|
|
62
|
+
* the new mode, or `null` when this session can't be in plan mode (an autonomous one).
|
|
63
|
+
*/
|
|
64
|
+
export declare function togglePlanMode(control: ModeControl): Mode | null;
|
|
52
65
|
/**
|
|
53
66
|
* User message queue backed by a single long-lived generator, for any multi-turn
|
|
54
67
|
* (chat-shaped) caller of `query()`.
|
|
@@ -59,8 +72,13 @@ export declare function createModeControl(initial: Mode): ModeControl;
|
|
|
59
72
|
* "ProcessTransport is not ready for writing". A real multi-turn conversation needs a
|
|
60
73
|
* single generator that never finishes on its own: messages get pushed into it with
|
|
61
74
|
* `push()` and it's only explicitly closed with `end()` when leaving the chat.
|
|
75
|
+
*
|
|
76
|
+
* With `modeControl`, each message goes after the note on a plan-mode switch the model
|
|
77
|
+
* hasn't been told about yet (`ModeControl.takeNotice()`).
|
|
62
78
|
*/
|
|
63
|
-
export declare function createInputQueue(
|
|
79
|
+
export declare function createInputQueue(options?: {
|
|
80
|
+
modeControl?: ModeControl;
|
|
81
|
+
}): {
|
|
64
82
|
push: (text: string) => void;
|
|
65
83
|
end: () => void;
|
|
66
84
|
iterable: AsyncIterable<SDKUserMessage>;
|
package/dist/core/session.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createSubagentBashGate } from "./hooks/subagentBashGate.js";
|
|
|
5
5
|
import { createSubagentTypeGate } from "./hooks/subagentTypeGate.js";
|
|
6
6
|
import { createSubagentForegroundGate } from "./hooks/subagentForegroundGate.js";
|
|
7
7
|
import { createFileScopeGate } from "./hooks/fileScopeGate.js";
|
|
8
|
+
import { createPlanGate } from "./hooks/planGate.js";
|
|
8
9
|
import { createHumanApprovalServer } from "./tools/humanApproval.js";
|
|
9
10
|
import { createManualLoginServer } from "./tools/manualLogin.js";
|
|
10
11
|
import { createSaveToSourcesServer } from "./tools/saveToSources.js";
|
|
@@ -25,8 +26,8 @@ import { createRunStore } from "./runs.js";
|
|
|
25
26
|
* fields on `BaseSessionConfig`.
|
|
26
27
|
*
|
|
27
28
|
* The per-mode behavioral differences (whether the approval/manual-intervention tools
|
|
28
|
-
* exist, whether the interactive step gate
|
|
29
|
-
* alone.
|
|
29
|
+
* exist, whether the interactive step gate or the plan gate decides) all fall out of
|
|
30
|
+
* `config.mode` and the session's `ModeControl` alone.
|
|
30
31
|
*
|
|
31
32
|
* `options.run` keeps the SDK's transcript of the conversation in that run folder (see
|
|
32
33
|
* runs.ts's createRunStore()) and, when it has a session already, resumes it: the chat
|
|
@@ -166,6 +167,21 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
166
167
|
// Registered wherever the approval tool is, and asking only while the current mode is
|
|
167
168
|
// "interactive": a guided session can switch to interactive and back (ModeControl).
|
|
168
169
|
...(includeApprovalTool ? [{ hooks: [createStepGate(runDir, () => modeControl.mode === "interactive")] }] : []),
|
|
170
|
+
// Likewise for "plan", which any non-autonomous session can switch into: the agent
|
|
171
|
+
// only reads and plans while it's on (hooks/planGate.ts).
|
|
172
|
+
...(includeApprovalTool
|
|
173
|
+
? [
|
|
174
|
+
{
|
|
175
|
+
hooks: [
|
|
176
|
+
createPlanGate({
|
|
177
|
+
projectDir: config.projectDir,
|
|
178
|
+
...(spec.planMode?.isPlanFile ? { isPlanFile: (filePath) => spec.planMode.isPlanFile(filePath, config) } : {}),
|
|
179
|
+
...(spec.planMode?.isReadOnlyTool ? { isReadOnlyTool: spec.planMode.isReadOnlyTool } : {}),
|
|
180
|
+
}, () => modeControl.mode === "plan"),
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
]
|
|
184
|
+
: []),
|
|
169
185
|
...(includeSubagentTools
|
|
170
186
|
? [
|
|
171
187
|
{ hooks: [createSubagentTypeGate(allowedSubagentTypes)] },
|
|
@@ -189,7 +205,10 @@ function skillList(skills, includeKnowledgeBase) {
|
|
|
189
205
|
/** A `ModeControl` starting at `initial` (`buildSessionOptions()` returns one for its session). */
|
|
190
206
|
export function createModeControl(initial) {
|
|
191
207
|
let current = initial;
|
|
192
|
-
|
|
208
|
+
// The mode the model was last told about: a session starting in plan mode tells it with
|
|
209
|
+
// the first message, since the system prompt doesn't say.
|
|
210
|
+
let told = initial === "plan" ? undefined : initial;
|
|
211
|
+
const switchable = initial === "autonomous" ? ["autonomous"] : ["guided", "interactive", "plan"];
|
|
193
212
|
return {
|
|
194
213
|
get mode() {
|
|
195
214
|
return current;
|
|
@@ -201,8 +220,40 @@ export function createModeControl(initial) {
|
|
|
201
220
|
current = next;
|
|
202
221
|
return true;
|
|
203
222
|
},
|
|
223
|
+
takeNotice() {
|
|
224
|
+
const before = told;
|
|
225
|
+
told = current;
|
|
226
|
+
if (current === "plan" && before !== "plan")
|
|
227
|
+
return PLAN_MODE_ENTERED;
|
|
228
|
+
if (current !== "plan" && before === "plan")
|
|
229
|
+
return PLAN_MODE_LEFT;
|
|
230
|
+
return undefined;
|
|
231
|
+
},
|
|
204
232
|
};
|
|
205
233
|
}
|
|
234
|
+
// The mode each session was in when `/plan` took it into plan mode, to go back to.
|
|
235
|
+
const modeBeforePlan = new WeakMap();
|
|
236
|
+
/**
|
|
237
|
+
* What the chats' `/plan` does: switches `control` into plan mode, or, when it's already
|
|
238
|
+
* there, back to the mode it was entered from ("guided" if it started in plan mode). Returns
|
|
239
|
+
* the new mode, or `null` when this session can't be in plan mode (an autonomous one).
|
|
240
|
+
*/
|
|
241
|
+
export function togglePlanMode(control) {
|
|
242
|
+
if (!control.switchable.includes("plan"))
|
|
243
|
+
return null;
|
|
244
|
+
if (control.mode === "plan") {
|
|
245
|
+
const back = modeBeforePlan.get(control) ?? "guided";
|
|
246
|
+
control.set(back);
|
|
247
|
+
return back;
|
|
248
|
+
}
|
|
249
|
+
modeBeforePlan.set(control, control.mode);
|
|
250
|
+
control.set("plan");
|
|
251
|
+
return "plan";
|
|
252
|
+
}
|
|
253
|
+
// What the model reads on a plan-mode switch, in English like the rest of what it reads
|
|
254
|
+
// (ADR-019); tagged so a resumed conversation doesn't show it as the human's words (runs.ts).
|
|
255
|
+
const PLAN_MODE_ENTERED = "<system-reminder>The user switched to plan mode. Only read, research and plan: nothing can be changed (files, tools that modify something) until they switch out of it. When the plan is ready, present it and wait for them.</system-reminder>";
|
|
256
|
+
const PLAN_MODE_LEFT = "<system-reminder>The user left plan mode: you can carry out the plan now.</system-reminder>";
|
|
206
257
|
/**
|
|
207
258
|
* User message queue backed by a single long-lived generator, for any multi-turn
|
|
208
259
|
* (chat-shaped) caller of `query()`.
|
|
@@ -213,8 +264,11 @@ export function createModeControl(initial) {
|
|
|
213
264
|
* "ProcessTransport is not ready for writing". A real multi-turn conversation needs a
|
|
214
265
|
* single generator that never finishes on its own: messages get pushed into it with
|
|
215
266
|
* `push()` and it's only explicitly closed with `end()` when leaving the chat.
|
|
267
|
+
*
|
|
268
|
+
* With `modeControl`, each message goes after the note on a plan-mode switch the model
|
|
269
|
+
* hasn't been told about yet (`ModeControl.takeNotice()`).
|
|
216
270
|
*/
|
|
217
|
-
export function createInputQueue() {
|
|
271
|
+
export function createInputQueue(options = {}) {
|
|
218
272
|
const pending = [];
|
|
219
273
|
let wake = null;
|
|
220
274
|
let ended = false;
|
|
@@ -236,7 +290,8 @@ export function createInputQueue() {
|
|
|
236
290
|
}
|
|
237
291
|
return {
|
|
238
292
|
push(text) {
|
|
239
|
-
|
|
293
|
+
const notice = options.modeControl?.takeNotice?.();
|
|
294
|
+
pending.push(notice ? `${notice}\n\n${text}` : text);
|
|
240
295
|
wake?.();
|
|
241
296
|
wake = null;
|
|
242
297
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,8 @@ export { createPromptLoader } from "./core/promptTemplate.js";
|
|
|
6
6
|
export { resolveClaudeAuth, type ClaudeAuthConfig } from "./core/claudeAuth.js";
|
|
7
7
|
export { ensureClaudeAuth } from "./tui/claudeAuth.js";
|
|
8
8
|
export { createFriendlyToolLabel, truncate, truncatePath, type ToolDescriber, } from "./core/toolLabels.js";
|
|
9
|
-
export type { Mode, BaseSessionConfig, AgentSpec } from "./core/agentSpec.js";
|
|
10
|
-
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, type ModeControl } from "./core/session.js";
|
|
9
|
+
export type { Mode, BaseSessionConfig, AgentSpec, PlanModeSpec } from "./core/agentSpec.js";
|
|
10
|
+
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode, type ModeControl } from "./core/session.js";
|
|
11
11
|
export { runQuery, type AgentEvent, type AgentRun, type SessionUsage, type ContextUsage } from "./core/runner.js";
|
|
12
12
|
export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
|
|
13
13
|
export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, type Language, type LanguageSources, type ResolvedLanguage, } from "./core/language.js";
|
|
@@ -21,6 +21,7 @@ export { createSubagentBashGate } from "./core/hooks/subagentBashGate.js";
|
|
|
21
21
|
export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
|
|
22
22
|
export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
|
|
23
23
|
export { createFileScopeGate, checkFileScope, type FileScope } from "./core/hooks/fileScopeGate.js";
|
|
24
|
+
export { createPlanGate, checkPlanScope, type PlanScope } from "./core/hooks/planGate.js";
|
|
24
25
|
export { askForDecision, askForManualIntervention } from "./core/hooks/humanInput.js";
|
|
25
26
|
export { setInteractionPort, getInteractionPort, type InteractionPort, type ApprovalPrompt } from "./core/interaction.js";
|
|
26
27
|
export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
|
|
@@ -31,7 +32,7 @@ export { runChatTui, type ChatTuiOptions } from "./tui/chatTui.js";
|
|
|
31
32
|
export type { SessionOpener } from "./tui/runs.js";
|
|
32
33
|
export { createConsoleRenderer, type ConsoleRenderer, type ConsoleRendererOptions } from "./tui/consoleRenderer.js";
|
|
33
34
|
export { runChatInk, type InkChatOptions, type HeaderInfo } from "./tui/ink/runChatInk.js";
|
|
34
|
-
export type { ToolPhrase } from "./tui/ink/toolGroup.js";
|
|
35
|
+
export type { ToolPhrase, ToolDetail, ResultFormatter } from "./tui/ink/toolGroup.js";
|
|
35
36
|
export type { RenderApproval } from "./tui/ink/SessionView.js";
|
|
36
37
|
export { createProgressView, type ProgressView, type ProgressViewOptions } from "./tui/ink/progressView.js";
|
|
37
38
|
export { runWizard, type WizardStep, type SelectStep, type InputStep, type PasswordStep, type ConfirmStep, type WizardAnswers, type WizardOptions, } from "./tui/ink/wizard.js";
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { createPromptLoader } from "./core/promptTemplate.js";
|
|
|
8
8
|
export { resolveClaudeAuth } from "./core/claudeAuth.js";
|
|
9
9
|
export { ensureClaudeAuth } from "./tui/claudeAuth.js";
|
|
10
10
|
export { createFriendlyToolLabel, truncate, truncatePath, } from "./core/toolLabels.js";
|
|
11
|
-
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl } from "./core/session.js";
|
|
11
|
+
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode } from "./core/session.js";
|
|
12
12
|
export { runQuery } from "./core/runner.js";
|
|
13
13
|
export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
|
|
14
14
|
export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, } from "./core/language.js";
|
|
@@ -23,6 +23,7 @@ export { createSubagentBashGate } from "./core/hooks/subagentBashGate.js";
|
|
|
23
23
|
export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
|
|
24
24
|
export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
|
|
25
25
|
export { createFileScopeGate, checkFileScope } from "./core/hooks/fileScopeGate.js";
|
|
26
|
+
export { createPlanGate, checkPlanScope } from "./core/hooks/planGate.js";
|
|
26
27
|
export { askForDecision, askForManualIntervention } from "./core/hooks/humanInput.js";
|
|
27
28
|
export { setInteractionPort, getInteractionPort } from "./core/interaction.js";
|
|
28
29
|
export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
|
package/dist/tui/chatTui.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { type ModeControl } from "../core/session.js";
|
|
2
3
|
import { type AgentEvent } from "../core/runner.js";
|
|
3
4
|
import { type Theme } from "./theme.js";
|
|
4
5
|
import { type SessionOpener } from "./runs.js";
|
|
@@ -72,6 +73,13 @@ export interface ChatTuiOptions {
|
|
|
72
73
|
* conversation, tool results included and not redacted: keep it out of version control.
|
|
73
74
|
*/
|
|
74
75
|
runsDir?: string;
|
|
76
|
+
/**
|
|
77
|
+
* The session's mode control (`buildSessionOptions()` returns it): `/plan` switches into
|
|
78
|
+
* plan mode and back (an autonomous session can't switch), and entering or leaving plan
|
|
79
|
+
* mode is told to the model with the next message. With a session opener, the one it
|
|
80
|
+
* returns is used instead.
|
|
81
|
+
*/
|
|
82
|
+
modeControl?: ModeControl;
|
|
75
83
|
/**
|
|
76
84
|
* Colors for the kit's roles (see `Theme`), on top of the kit's defaults: only the roles
|
|
77
85
|
* given change, e.g. `{ toolResult: "yellow", selection: "#00ff00" }`. One theme per
|
package/dist/tui/chatTui.js
CHANGED
|
@@ -3,7 +3,7 @@ import { stdin, stdout } from "node:process";
|
|
|
3
3
|
import { createWriteStream } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { createInputQueue } from "../core/session.js";
|
|
6
|
+
import { createInputQueue, togglePlanMode } from "../core/session.js";
|
|
7
7
|
import { runQuery } from "../core/runner.js";
|
|
8
8
|
import { listRuns, readConversation } from "../core/runs.js";
|
|
9
9
|
import { isSharedQuestionActive, setSharedReadline } from "./terminalInteraction.js";
|
|
@@ -116,7 +116,8 @@ export async function runChatTui(options, tuiOptions = {}) {
|
|
|
116
116
|
throw new Error("runChatTui(): a session opener needs `runsDir`.");
|
|
117
117
|
// The session in use: with runs, the run's folder, rebuilt on /resume.
|
|
118
118
|
let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
|
|
119
|
-
|
|
119
|
+
const modeControl = () => current.modeControl ?? tuiOptions.modeControl;
|
|
120
|
+
let queue = createInputQueue({ modeControl: modeControl() });
|
|
120
121
|
let run = runQuery(queue.iterable, current.options);
|
|
121
122
|
let events = run.events[Symbol.asyncIterator]();
|
|
122
123
|
const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
@@ -257,7 +258,7 @@ export async function runChatTui(options, tuiOptions = {}) {
|
|
|
257
258
|
run.close();
|
|
258
259
|
current = await openSession(opener, await runFolderOf(summary));
|
|
259
260
|
openLog();
|
|
260
|
-
queue = createInputQueue();
|
|
261
|
+
queue = createInputQueue({ modeControl: modeControl() });
|
|
261
262
|
run = runQuery(queue.iterable, current.options);
|
|
262
263
|
events = run.events[Symbol.asyncIterator]();
|
|
263
264
|
knownCommandTokens = null;
|
|
@@ -299,6 +300,13 @@ export async function runChatTui(options, tuiOptions = {}) {
|
|
|
299
300
|
await resume();
|
|
300
301
|
continue;
|
|
301
302
|
}
|
|
303
|
+
// Plan mode on and off (the Ink chat also has Shift+Tab); the line says where it is now.
|
|
304
|
+
if (line.toLowerCase() === "/plan") {
|
|
305
|
+
const control = modeControl();
|
|
306
|
+
const next = control ? togglePlanMode(control) : null;
|
|
307
|
+
writeLine(ui.dim(next ? `⏵⏵ ${t().mode(next)}` : t().modeLocked(control?.mode)));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
302
310
|
// A line that *looks* like a slash command but matches nothing registered (a typo,
|
|
303
311
|
// an unnamespaced plugin command, ...) would otherwise reach the model as literal
|
|
304
312
|
// text with no special handling — confirmed empirically (captain-whiskers'
|
|
@@ -9,9 +9,13 @@ export type RenderApproval = (prompt: ApprovalPrompt) => ReactNode;
|
|
|
9
9
|
/**
|
|
10
10
|
* The preview's lines, capped so the whole live area stays shorter than the terminal: Ink
|
|
11
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.
|
|
12
|
+
* preview carries the tool's parameters, which can be a whole file. `reservedRows` is what
|
|
13
|
+
* the rest of the screen needs besides the preview; full screen adds its pinned header, or
|
|
14
|
+
* the panel's options and the status bar end up below the terminal's last row. With `width`
|
|
15
|
+
* (the panel's inner width) the cap counts screen rows, not lines: a long summary line takes
|
|
16
|
+
* several.
|
|
13
17
|
*/
|
|
14
|
-
export declare function previewLines(lines: readonly string[], terminalRows: number | undefined): string[];
|
|
18
|
+
export declare function previewLines(lines: readonly string[], terminalRows: number | undefined, reservedRows?: number, width?: number): string[];
|
|
15
19
|
export declare function statusText(mode: Mode | undefined, turns: number, usage: SessionUsage | null, extra?: {
|
|
16
20
|
contextPercent?: number | null;
|
|
17
21
|
modeSwitchable?: boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import wrapAnsi from "wrap-ansi";
|
|
2
3
|
import { useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react";
|
|
3
4
|
import { Box, measureElement, Static, Text, useInput, useStdout } from "ink";
|
|
4
5
|
import { Select } from "@inkjs/ui";
|
|
@@ -18,17 +19,30 @@ const decisionOptions = () => [
|
|
|
18
19
|
];
|
|
19
20
|
const manualOptions = () => [{ label: `1. ${t().doneContinue}`, value: "continue" }];
|
|
20
21
|
const DECISION_KEYS = { "1": "y", "2": "n", "3": "q", y: "y", n: "n", q: "q" };
|
|
22
|
+
/**
|
|
23
|
+
* The rows a checkpoint panel's preview leaves to everything else: the panel's own frame
|
|
24
|
+
* (borders, title, question, options: 8 rows), the status bar and a little room.
|
|
25
|
+
*/
|
|
26
|
+
const PANEL_RESERVED_ROWS = 12;
|
|
27
|
+
/** The panel's border and horizontal padding, on both sides. */
|
|
28
|
+
const PANEL_FRAME_COLUMNS = 4;
|
|
21
29
|
/**
|
|
22
30
|
* The preview's lines, capped so the whole live area stays shorter than the terminal: Ink
|
|
23
31
|
* clears the entire screen (scrollback included) to redraw anything taller, and a step-gate
|
|
24
|
-
* preview carries the tool's parameters, which can be a whole file.
|
|
32
|
+
* preview carries the tool's parameters, which can be a whole file. `reservedRows` is what
|
|
33
|
+
* the rest of the screen needs besides the preview; full screen adds its pinned header, or
|
|
34
|
+
* the panel's options and the status bar end up below the terminal's last row. With `width`
|
|
35
|
+
* (the panel's inner width) the cap counts screen rows, not lines: a long summary line takes
|
|
36
|
+
* several.
|
|
25
37
|
*/
|
|
26
|
-
export function previewLines(lines, terminalRows) {
|
|
27
|
-
const all = lines
|
|
28
|
-
|
|
38
|
+
export function previewLines(lines, terminalRows, reservedRows = PANEL_RESERVED_ROWS, width) {
|
|
39
|
+
const all = lines
|
|
40
|
+
.flatMap((line) => line.split("\n"))
|
|
41
|
+
.flatMap((line) => (width ? wrapAnsi(line, Math.max(1, width), { hard: true, trim: false }).split("\n") : [line]));
|
|
42
|
+
const max = Math.max(3, (terminalRows || 24) - reservedRows);
|
|
29
43
|
return all.length <= max ? all : [...all.slice(0, max - 1), ui.dim(t().moreLines(all.length - max + 1))];
|
|
30
44
|
}
|
|
31
|
-
function CheckpointPanel({ checkpoint, renderApproval }) {
|
|
45
|
+
function CheckpointPanel({ checkpoint, renderApproval, reservedRows, width, }) {
|
|
32
46
|
const decision = checkpoint.kind === "decision";
|
|
33
47
|
useInput((input) => {
|
|
34
48
|
const key = input.toLowerCase();
|
|
@@ -39,7 +53,7 @@ function CheckpointPanel({ checkpoint, renderApproval }) {
|
|
|
39
53
|
});
|
|
40
54
|
const { prompt } = checkpoint;
|
|
41
55
|
const { stdout } = useStdout();
|
|
42
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("accent"), paddingX: 1, children: [renderApproval ? (renderApproval(prompt)) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: prompt.title }), previewLines(prompt.lines, stdout.rows, reservedRows, width - PANEL_FRAME_COLUMNS).map((line, index) => (_jsx(Text, { children: line }, index))), !decision && prompt.question ? _jsx(Text, { dimColor: true, children: prompt.question }) : null] })), decision ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: t().proceed }) })) : null, _jsx(Box, { marginTop: decision ? 0 : 1, children: _jsx(Select, { options: decision ? decisionOptions() : manualOptions(), onChange: (value) => checkpoint.answer(decision ? value : "") }, checkpoint.id) })] }));
|
|
43
57
|
}
|
|
44
58
|
function formatTokens(count) {
|
|
45
59
|
return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
|
|
@@ -67,12 +81,12 @@ function Working({ label, startedAt, width }) {
|
|
|
67
81
|
return _jsx(Text, { children: fitWidth(`${ui.working(`${glyph} ${label}`)} ${ui.dim(`(${seconds}s · ${t().escToInterrupt})`)}`, width) });
|
|
68
82
|
}
|
|
69
83
|
/** Line in progress, checkpoint panel or spinner, the input, and the status bar. */
|
|
70
|
-
function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtra, modeSwitchable, children }) {
|
|
84
|
+
function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtra, modeSwitchable, reservedRows, children }) {
|
|
71
85
|
// The mode in the spinner's color; the rest dim.
|
|
72
86
|
const plain = statusText(mode, session.turns, session.usage, { contextPercent: session.contextPercent, modeSwitchable }) + (statusExtra ? ` · ${statusExtra}` : "");
|
|
73
87
|
const cut = mode ? plain.indexOf(" · ") : -1;
|
|
74
88
|
const status = cut > 0 ? ui.working(plain.slice(0, cut)) + ui.dim(plain.slice(cut)) : ui.dim(plain);
|
|
75
|
-
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 ? (
|
|
89
|
+
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, reservedRows: reservedRows, width: width })) : session.busy ? (
|
|
76
90
|
// Always one blank line above the spinner: from this margin, or from the live area's
|
|
77
91
|
// own when a separator is pending and nothing is written above the spinner yet.
|
|
78
92
|
_jsxs(Box, { flexDirection: "column", marginTop: stripAnsi(session.live) || !session.liveGap ? 1 : 0, children: [_jsx(Working, { label: session.activity ?? t().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) })] }));
|
|
@@ -147,12 +161,17 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
|
|
|
147
161
|
if (!historyRef.current || end === start)
|
|
148
162
|
return null;
|
|
149
163
|
const box = framePosition(historyRef.current);
|
|
150
|
-
|
|
164
|
+
// Measured now, not `historyHeight`: that state is set by an effect after the layout, so
|
|
165
|
+
// a click on the first frames would find the rows where they were before it.
|
|
166
|
+
const height = measureElement(historyRef.current).height;
|
|
167
|
+
const firstY = box.y + height - (end - start); // the rows are bottom-aligned
|
|
151
168
|
let offset = y - firstY;
|
|
152
|
-
|
|
169
|
+
// Rows that don't fit are clipped above the box, not clickable.
|
|
170
|
+
const firstShown = Math.max(0, box.y - firstY);
|
|
171
|
+
if (offset < firstShown || offset >= end - start) {
|
|
153
172
|
if (!clamp)
|
|
154
173
|
return null;
|
|
155
|
-
offset = Math.min(end - start - 1, Math.max(
|
|
174
|
+
offset = Math.min(end - start - 1, Math.max(firstShown, offset));
|
|
156
175
|
}
|
|
157
176
|
return { row: start + offset, col: Math.max(0, x - box.x) };
|
|
158
177
|
}
|
|
@@ -209,7 +228,9 @@ function FullscreenSession({ session, checkpoint, renderApproval, mode, header,
|
|
|
209
228
|
const statusExtra = [anchor === null ? null : t().linesBelow(below), copied]
|
|
210
229
|
.filter(Boolean)
|
|
211
230
|
.join(" · ");
|
|
212
|
-
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,
|
|
231
|
+
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,
|
|
232
|
+
// The pinned header (and the blank row under it) is taken from the preview too.
|
|
233
|
+
reservedRows: PANEL_RESERVED_ROWS + (header && header.length > 0 ? header.length + 1 : 0), children: children })] }));
|
|
213
234
|
}
|
|
214
235
|
/** History, line in progress, current action, checkpoint panel and status bar. */
|
|
215
236
|
export function SessionView({ model, interaction, renderApproval, mode, closed, fullscreen, header, onCopy, modeSwitchable, children }) {
|
|
@@ -9,4 +9,9 @@ export declare function renderMarkdown(source: string, width: number): string[];
|
|
|
9
9
|
* Where the finished blocks end in a reply still streaming: after its last blank line
|
|
10
10
|
* outside a code fence. Everything before it renders for good; the rest is still growing.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Whether `source` holds a table: its columns are sized from all its rows, so none of its
|
|
14
|
+
* rows is final until the table is (see the session model's streaming).
|
|
15
|
+
*/
|
|
16
|
+
export declare function hasTable(source: string): boolean;
|
|
12
17
|
export declare function finishedLength(source: string): number;
|
package/dist/tui/ink/markdown.js
CHANGED
|
@@ -173,6 +173,13 @@ function decodeEntities(text) {
|
|
|
173
173
|
* Where the finished blocks end in a reply still streaming: after its last blank line
|
|
174
174
|
* outside a code fence. Everything before it renders for good; the rest is still growing.
|
|
175
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* Whether `source` holds a table: its columns are sized from all its rows, so none of its
|
|
178
|
+
* rows is final until the table is (see the session model's streaming).
|
|
179
|
+
*/
|
|
180
|
+
export function hasTable(source) {
|
|
181
|
+
return marked.lexer(source, { gfm: true }).some((token) => token.type === "table");
|
|
182
|
+
}
|
|
176
183
|
export function finishedLength(source) {
|
|
177
184
|
let fenceOpen = false;
|
|
178
185
|
let cut = 0;
|
|
@@ -2,12 +2,16 @@ import type { Mode } from "../../core/agentSpec.js";
|
|
|
2
2
|
import { type ConsoleRenderer, type ConsoleRendererOptions } from "../consoleRenderer.js";
|
|
3
3
|
import { type SessionModel } from "./sessionModel.js";
|
|
4
4
|
import { type RenderApproval } from "./SessionView.js";
|
|
5
|
-
import type { ToolPhrase } from "./toolGroup.js";
|
|
5
|
+
import type { ResultFormatter, ToolDetail, ToolPhrase } from "./toolGroup.js";
|
|
6
6
|
import { type Theme } from "../theme.js";
|
|
7
7
|
/** Options of `createProgressView()`. */
|
|
8
8
|
export interface ProgressViewOptions extends Omit<ConsoleRendererOptions, "output"> {
|
|
9
9
|
/** How one of the agent's own tools counts in a folded group's summary (see `InkChatOptions.toolPhrase`). */
|
|
10
10
|
toolPhrase?: (toolName: string) => ToolPhrase | undefined;
|
|
11
|
+
/** How much of the tool calls shows (see `InkChatOptions.toolDetail`); "full" if not given. */
|
|
12
|
+
toolDetail?: ToolDetail;
|
|
13
|
+
/** The result line under a tool call (see `InkChatOptions.formatResult`). */
|
|
14
|
+
formatResult?: ResultFormatter;
|
|
11
15
|
/** Replaces the default preview in the approval and manual-intervention panels. */
|
|
12
16
|
renderApproval?: RenderApproval;
|
|
13
17
|
/** Shown in the status bar. */
|
|
@@ -4,7 +4,7 @@ import { type ModeControl } from "../../core/session.js";
|
|
|
4
4
|
import { type ChatTuiOptions } from "../chatTui.js";
|
|
5
5
|
import { type SessionOpener } from "../runs.js";
|
|
6
6
|
import { type RenderApproval } from "./SessionView.js";
|
|
7
|
-
import type { ToolPhrase } from "./toolGroup.js";
|
|
7
|
+
import type { ResultFormatter, ToolDetail, ToolPhrase } from "./toolGroup.js";
|
|
8
8
|
import { type HeaderInfo } from "./header.js";
|
|
9
9
|
export type { HeaderInfo } from "./header.js";
|
|
10
10
|
/** Options of `runChatInk()`: the chat's options plus the Ink-only ones. */
|
|
@@ -45,11 +45,27 @@ export interface InkChatOptions extends ChatTuiOptions {
|
|
|
45
45
|
* counts as "used {n} tools".
|
|
46
46
|
*/
|
|
47
47
|
toolPhrase?: (toolName: string) => ToolPhrase | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* How much of the tool calls shows: `"full"` (the default), every call with its result
|
|
50
|
+
* line; `"calls"`, the calls without their results, a failed one with a short line in the
|
|
51
|
+
* kit's language instead of the tool's error; `"summary"`, one line per group ("Read 2
|
|
52
|
+
* files, ran 1 shell command"). Ctrl+O unfolds any of them into the full view. For an
|
|
53
|
+
* audience that doesn't need the tools' raw output. The session log is the same whatever
|
|
54
|
+
* the level.
|
|
55
|
+
*/
|
|
56
|
+
toolDetail?: ToolDetail;
|
|
57
|
+
/**
|
|
58
|
+
* The line under a tool call for its result, e.g. a friendly one for an MCP tool whose
|
|
59
|
+
* output is written for the model: a string replaces the kit's (the result's first line),
|
|
60
|
+
* `null` hides it, `undefined` keeps it. Shown wherever results are (`"full"`, or unfolded).
|
|
61
|
+
*/
|
|
62
|
+
formatResult?: ResultFormatter;
|
|
48
63
|
/**
|
|
49
64
|
* The session's mode control (`buildSessionOptions()` returns it): the status bar shows the
|
|
50
|
-
* current mode,
|
|
51
|
-
* "
|
|
52
|
-
*
|
|
65
|
+
* current mode, Shift+Tab switches between the modes it allows ("guided", "interactive"
|
|
66
|
+
* and "plan"; an autonomous session can't switch), and entering or leaving plan mode is
|
|
67
|
+
* told to the model with the next message. With a session opener, the one it returns is
|
|
68
|
+
* used instead.
|
|
53
69
|
*/
|
|
54
70
|
modeControl?: ModeControl;
|
|
55
71
|
/**
|
|
@@ -6,7 +6,7 @@ import { useSyncExternalStore } from "react";
|
|
|
6
6
|
import { Box, render, Text, useInput } from "ink";
|
|
7
7
|
import { Select } from "@inkjs/ui";
|
|
8
8
|
import { getInteractionPort, setInteractionPort } from "../../core/interaction.js";
|
|
9
|
-
import { createInputQueue } from "../../core/session.js";
|
|
9
|
+
import { createInputQueue, togglePlanMode } from "../../core/session.js";
|
|
10
10
|
import { runQuery } from "../../core/runner.js";
|
|
11
11
|
import { listRuns, readConversation } from "../../core/runs.js";
|
|
12
12
|
import { capHistory, loadHistory, runChatTui, saveHistory, slashCommandToken } from "../chatTui.js";
|
|
@@ -210,6 +210,8 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
210
210
|
const model = createSessionModel({
|
|
211
211
|
formatAction: tuiOptions.formatAction,
|
|
212
212
|
toolPhrase: tuiOptions.toolPhrase,
|
|
213
|
+
toolDetail: tuiOptions.toolDetail,
|
|
214
|
+
formatResult: tuiOptions.formatResult,
|
|
213
215
|
agentLabel: tuiOptions.agentLabel,
|
|
214
216
|
onWrite: mirror,
|
|
215
217
|
width: () => liveWidth(stdout.columns),
|
|
@@ -244,7 +246,7 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
244
246
|
let generation = 0;
|
|
245
247
|
function connect() {
|
|
246
248
|
const own = ++generation;
|
|
247
|
-
queue = createInputQueue();
|
|
249
|
+
queue = createInputQueue({ modeControl: modeControl() });
|
|
248
250
|
run = runQuery(queue.iterable, { ...current.options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
|
|
249
251
|
const events = run.events[Symbol.asyncIterator]();
|
|
250
252
|
input.setMode(modeControl()?.mode ?? null);
|
|
@@ -253,7 +255,11 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
253
255
|
knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
|
|
254
256
|
void knownCommands.then((names) => {
|
|
255
257
|
const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
|
|
256
|
-
const local = [
|
|
258
|
+
const local = [
|
|
259
|
+
...(tuiOptions.terminalIntegration === false ? [] : ["copy"]),
|
|
260
|
+
...(runsDir ? ["resume"] : []),
|
|
261
|
+
...(modeControl()?.switchable.includes("plan") ? ["plan"] : []),
|
|
262
|
+
];
|
|
257
263
|
input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
|
|
258
264
|
});
|
|
259
265
|
void (async () => {
|
|
@@ -382,6 +388,16 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
382
388
|
await resume();
|
|
383
389
|
continue;
|
|
384
390
|
}
|
|
391
|
+
// A local command too: into plan mode, or back to the mode it was entered from.
|
|
392
|
+
if (line.toLowerCase() === "/plan") {
|
|
393
|
+
const control = modeControl();
|
|
394
|
+
const next = control ? togglePlanMode(control) : null;
|
|
395
|
+
if (next)
|
|
396
|
+
input.setMode(next);
|
|
397
|
+
else
|
|
398
|
+
model.writeLine(ui.dim(t().modeLocked(control?.mode ?? tuiOptions.mode)));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
385
401
|
const commandToken = slashCommandToken(line);
|
|
386
402
|
if (commandToken) {
|
|
387
403
|
const names = await knownCommands;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { AgentEvent, SessionUsage } from "../../core/runner.js";
|
|
2
2
|
import { type ConsoleRenderer } from "../consoleRenderer.js";
|
|
3
|
-
import { type ToolPhrase } from "./toolGroup.js";
|
|
3
|
+
import { type ResultFormatter, type ToolDetail, type ToolPhrase } from "./toolGroup.js";
|
|
4
4
|
export interface HistoryItem {
|
|
5
5
|
id: number;
|
|
6
6
|
text: string;
|
|
7
7
|
/** What it is: the view draws the human's lines as a bar, and the spacing follows it. */
|
|
8
8
|
kind?: OutputKind;
|
|
9
|
-
/** A
|
|
9
|
+
/** A group of tool calls: the lines it unfolds into with Ctrl+O (`text` is the folded view). */
|
|
10
10
|
expanded?: string[];
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
@@ -30,7 +30,7 @@ export interface SessionSnapshot {
|
|
|
30
30
|
activity: string | null;
|
|
31
31
|
/** Label of the latest action taken inside a subagent in this turn. */
|
|
32
32
|
subagentActivity: string | null;
|
|
33
|
-
/** Tool
|
|
33
|
+
/** Tool groups unfolded into every call with its result (Ctrl+O toggles it); folded, they show as `toolDetail` says. */
|
|
34
34
|
expanded: boolean;
|
|
35
35
|
turns: number;
|
|
36
36
|
usage: SessionUsage | null;
|
|
@@ -41,6 +41,10 @@ export interface SessionModelOptions {
|
|
|
41
41
|
formatAction?: (toolName: string, toolInput: unknown) => string;
|
|
42
42
|
/** How a tool counts in a folded group's summary ("read 2 files"); built-in tools have their own. */
|
|
43
43
|
toolPhrase?: (toolName: string) => ToolPhrase | undefined;
|
|
44
|
+
/** How much of the tool calls shows until Ctrl+O (see `ToolDetail`); "full" if not given. */
|
|
45
|
+
toolDetail?: ToolDetail;
|
|
46
|
+
/** The result line under a tool call (see `ResultFormatter`). */
|
|
47
|
+
formatResult?: ResultFormatter;
|
|
44
48
|
agentLabel?: string;
|
|
45
49
|
/** Called with everything the console renderer writes, e.g. to mirror a session log. */
|
|
46
50
|
onWrite?: (text: string) => void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createFriendlyToolLabel } from "../../core/toolLabels.js";
|
|
2
2
|
import { createConsoleRenderer } from "../consoleRenderer.js";
|
|
3
3
|
import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
4
|
-
import { finishedLength, renderMarkdown } from "./markdown.js";
|
|
4
|
+
import { finishedLength, hasTable, renderMarkdown } from "./markdown.js";
|
|
5
5
|
import { toolGroupExpanded, toolGroupSummary } from "./toolGroup.js";
|
|
6
6
|
import * as ui from "../ui.js";
|
|
7
7
|
import { t } from "../../core/messages/index.js";
|
|
@@ -29,6 +29,7 @@ const isBlank = (line) => stripAnsi(line).trim() === "";
|
|
|
29
29
|
*/
|
|
30
30
|
export function createSessionModel(options = {}) {
|
|
31
31
|
const formatAction = options.formatAction ?? createFriendlyToolLabel();
|
|
32
|
+
const toolDetail = options.toolDetail ?? "full";
|
|
32
33
|
const width = () => Math.max(20, options.width?.() ?? 80);
|
|
33
34
|
const listeners = new Set();
|
|
34
35
|
let nextId = 0;
|
|
@@ -40,7 +41,7 @@ export function createSessionModel(options = {}) {
|
|
|
40
41
|
turnStartedAt: null,
|
|
41
42
|
activity: null,
|
|
42
43
|
subagentActivity: null,
|
|
43
|
-
expanded:
|
|
44
|
+
expanded: toolDetail === "full",
|
|
44
45
|
turns: 0,
|
|
45
46
|
usage: null,
|
|
46
47
|
contextPercent: null,
|
|
@@ -120,6 +121,15 @@ export function createSessionModel(options = {}) {
|
|
|
120
121
|
if (!rest.trim())
|
|
121
122
|
return [];
|
|
122
123
|
const rows = renderMarkdown(rest, mdWidth());
|
|
124
|
+
// A table's columns are sized from all its rows, so a later row can widen rows already
|
|
125
|
+
// drawn: an unfinished table is never moved to the history, and only its latest rows
|
|
126
|
+
// show live until its block ends and it goes to the history whole.
|
|
127
|
+
if (hasTable(rest)) {
|
|
128
|
+
const shown = segmentShown;
|
|
129
|
+
const live = decorate(rows.slice(Math.max(rowsCommitted, rows.length - LIVE_ROWS)));
|
|
130
|
+
segmentShown = shown;
|
|
131
|
+
return live;
|
|
132
|
+
}
|
|
123
133
|
const overflow = rows.length - rowsCommitted - LIVE_ROWS;
|
|
124
134
|
if (overflow > 0) {
|
|
125
135
|
const lead = segmentShown && rowsCommitted === 0 ? [""] : [];
|
|
@@ -137,7 +147,15 @@ export function createSessionModel(options = {}) {
|
|
|
137
147
|
return;
|
|
138
148
|
const calls = group;
|
|
139
149
|
group = null;
|
|
140
|
-
push([
|
|
150
|
+
push([folded(calls).join("\n")], "action", { expanded: unfolded(calls) });
|
|
151
|
+
}
|
|
152
|
+
/** A group with every call and its result: "full", or any level unfolded with Ctrl+O. */
|
|
153
|
+
function unfolded(calls) {
|
|
154
|
+
return toolGroupExpanded(calls, width(), { formatResult: options.formatResult });
|
|
155
|
+
}
|
|
156
|
+
/** A group as its level shows it folded: the calls without results, or one summary line. */
|
|
157
|
+
function folded(calls) {
|
|
158
|
+
return toolDetail === "calls" ? toolGroupExpanded(calls, width(), { results: false }) : [groupLine(calls)];
|
|
141
159
|
}
|
|
142
160
|
function groupLine(calls) {
|
|
143
161
|
return INDENT + ui.dim(fitWidth(toolGroupSummary(calls, options.toolPhrase), width() - INDENT.length));
|
|
@@ -152,7 +170,7 @@ export function createSessionModel(options = {}) {
|
|
|
152
170
|
let rows = liveRows;
|
|
153
171
|
let kind = liveRows.length > 0 ? "agent" : null;
|
|
154
172
|
if (group) {
|
|
155
|
-
rows = snapshot.expanded ?
|
|
173
|
+
rows = snapshot.expanded ? unfolded(group) : folded(group);
|
|
156
174
|
kind = "action";
|
|
157
175
|
}
|
|
158
176
|
const live = rows.join("\n");
|
|
@@ -13,6 +13,27 @@ export interface ToolCall {
|
|
|
13
13
|
}
|
|
14
14
|
/** How a tool counts in a group's summary: the phrase for one call and for several, "{n}" standing for the count. */
|
|
15
15
|
export type { ToolPhrase };
|
|
16
|
+
/**
|
|
17
|
+
* How much of its tool calls a chat shows: `"full"`, every call with its result line;
|
|
18
|
+
* `"calls"`, the calls without their results (a failed call says so in one short line);
|
|
19
|
+
* `"summary"`, one line per group. Ctrl+O unfolds any of them into the full view.
|
|
20
|
+
*/
|
|
21
|
+
export type ToolDetail = "full" | "calls" | "summary";
|
|
22
|
+
/**
|
|
23
|
+
* The line shown under a tool call for its result: a string replaces the kit's (plain text,
|
|
24
|
+
* drawn in the result's color, or the error color if it failed), `null` hides it, and
|
|
25
|
+
* `undefined` keeps the kit's (the result's first line).
|
|
26
|
+
*/
|
|
27
|
+
export type ResultFormatter = (toolName: string, result: {
|
|
28
|
+
isError: boolean;
|
|
29
|
+
text: string;
|
|
30
|
+
}) => string | null | undefined;
|
|
31
|
+
/** How `toolGroupExpanded()` draws the results. */
|
|
32
|
+
export interface ResultOptions {
|
|
33
|
+
/** Show the result lines (the default); without them a failed call shows a short failure line. */
|
|
34
|
+
results?: boolean;
|
|
35
|
+
formatResult?: ResultFormatter;
|
|
36
|
+
}
|
|
16
37
|
/**
|
|
17
38
|
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
18
39
|
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
@@ -32,6 +53,6 @@ export declare function resultSummary(result: {
|
|
|
32
53
|
/**
|
|
33
54
|
* The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
|
|
34
55
|
* latest few, dim) and the result in one line (a subagent's answer with its markdown
|
|
35
|
-
* rendered), or "…" while it runs.
|
|
56
|
+
* rendered), or "…" while it runs. Without `options.results`, only a failure's short line.
|
|
36
57
|
*/
|
|
37
|
-
export declare function toolGroupExpanded(calls: readonly ToolCall[], width: number): string[];
|
|
58
|
+
export declare function toolGroupExpanded(calls: readonly ToolCall[], width: number, options?: ResultOptions): string[];
|
|
@@ -43,20 +43,33 @@ export function resultSummary(result, markdown = false) {
|
|
|
43
43
|
// would cancel a dim around it.
|
|
44
44
|
return ui.toolResult(markdown ? stripAnsi(renderMarkdown(first, 1000)[0] ?? first) : first) + more;
|
|
45
45
|
}
|
|
46
|
+
/** The line under a call for its result, or null for none. */
|
|
47
|
+
function resultLine(call, { results = true, formatResult }) {
|
|
48
|
+
if (!call.result)
|
|
49
|
+
return results ? ui.dim("…") : null;
|
|
50
|
+
if (!results)
|
|
51
|
+
return call.result.isError ? ui.error(t().toolFailed) : null;
|
|
52
|
+
const custom = formatResult?.(call.toolName, call.result);
|
|
53
|
+
if (custom === null)
|
|
54
|
+
return null;
|
|
55
|
+
if (custom !== undefined)
|
|
56
|
+
return call.result.isError ? ui.error(custom) : ui.toolResult(custom);
|
|
57
|
+
return resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName));
|
|
58
|
+
}
|
|
46
59
|
/**
|
|
47
60
|
* The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
|
|
48
61
|
* latest few, dim) and the result in one line (a subagent's answer with its markdown
|
|
49
|
-
* rendered), or "…" while it runs.
|
|
62
|
+
* rendered), or "…" while it runs. Without `options.results`, only a failure's short line.
|
|
50
63
|
*/
|
|
51
|
-
export function toolGroupExpanded(calls, width) {
|
|
64
|
+
export function toolGroupExpanded(calls, width, options = {}) {
|
|
52
65
|
return calls.flatMap((call) => {
|
|
53
66
|
const children = call.children ?? [];
|
|
54
67
|
const shown = children.slice(-MAX_CHILDREN);
|
|
55
|
-
const result =
|
|
68
|
+
const result = resultLine(call, options);
|
|
56
69
|
const under = [
|
|
57
70
|
...(children.length > shown.length ? [ui.dim(t().earlierCalls(children.length - shown.length))] : []),
|
|
58
71
|
...shown.map((label) => fitWidth(ui.dim(`· ${label}`), width - 5)),
|
|
59
|
-
result,
|
|
72
|
+
...(result === null ? [] : [fitWidth(result, width - 5)]),
|
|
60
73
|
];
|
|
61
74
|
return [
|
|
62
75
|
`${ui.toolBullet("●")} ${fitWidth(ui.toolLabel(call.label), width - 2)}`,
|