@falkenslab/agent-kit 0.10.2 → 0.13.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 +105 -97
- package/dist/core/agentSpec.d.ts +31 -1
- package/dist/core/claudeAuth.d.ts +1 -0
- package/dist/core/hooks/stepGate.js +3 -2
- package/dist/core/hooks/transcriptLogger.d.ts +1 -0
- package/dist/core/interaction.d.ts +3 -0
- package/dist/core/interaction.js +2 -0
- package/dist/core/language.d.ts +41 -0
- package/dist/core/language.js +60 -0
- package/dist/core/messages/de.d.ts +2 -0
- package/dist/core/messages/de.js +103 -0
- package/dist/core/messages/en.d.ts +90 -0
- package/dist/core/messages/en.js +102 -0
- package/dist/core/messages/es.d.ts +2 -0
- package/dist/core/messages/es.js +103 -0
- package/dist/core/messages/fr.d.ts +2 -0
- package/dist/core/messages/fr.js +103 -0
- package/dist/core/messages/index.d.ts +23 -0
- package/dist/core/messages/index.js +67 -0
- package/dist/core/runner.d.ts +2 -0
- package/dist/core/runs.d.ts +53 -0
- package/dist/core/runs.js +194 -0
- package/dist/core/session.d.ts +9 -0
- package/dist/core/session.js +43 -7
- package/dist/core/toolLabels.d.ts +8 -1
- package/dist/core/toolLabels.js +36 -54
- package/dist/core/tools/humanApproval.d.ts +1 -0
- package/dist/core/tools/humanApproval.js +3 -2
- package/dist/core/tools/manualLogin.d.ts +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/tui/chatTui.d.ts +31 -2
- package/dist/tui/chatTui.js +73 -7
- package/dist/tui/claudeAuth.d.ts +3 -1
- package/dist/tui/claudeAuth.js +12 -17
- package/dist/tui/consoleRenderer.d.ts +2 -0
- package/dist/tui/consoleRenderer.js +2 -1
- package/dist/tui/ink/PromptInput.d.ts +0 -2
- package/dist/tui/ink/PromptInput.js +5 -15
- package/dist/tui/ink/SessionView.js +17 -16
- package/dist/tui/ink/header.d.ts +1 -0
- package/dist/tui/ink/inkInteraction.js +4 -7
- package/dist/tui/ink/inkTheme.d.ts +9 -0
- package/dist/tui/ink/inkTheme.js +25 -0
- package/dist/tui/ink/markdown.js +49 -8
- package/dist/tui/ink/progressView.d.ts +15 -0
- package/dist/tui/ink/progressView.js +6 -1
- package/dist/tui/ink/promptEditing.d.ts +2 -5
- package/dist/tui/ink/promptEditing.js +11 -3
- package/dist/tui/ink/runChatInk.d.ts +26 -2
- package/dist/tui/ink/runChatInk.js +159 -48
- package/dist/tui/ink/sessionModel.d.ts +10 -0
- package/dist/tui/ink/sessionModel.js +27 -2
- package/dist/tui/ink/toolGroup.d.ts +2 -1
- package/dist/tui/ink/toolGroup.js +10 -21
- package/dist/tui/ink/wizard.d.ts +20 -0
- package/dist/tui/ink/wizard.js +8 -2
- package/dist/tui/language.d.ts +5 -0
- package/dist/tui/language.js +11 -0
- package/dist/tui/runs.d.ts +27 -0
- package/dist/tui/runs.js +30 -0
- package/dist/tui/terminalInteraction.d.ts +2 -0
- package/dist/tui/terminalInteraction.js +4 -2
- package/dist/tui/theme.d.ts +60 -0
- package/dist/tui/theme.js +65 -0
- package/dist/tui/ui.d.ts +23 -3
- package/dist/tui/ui.js +42 -18
- package/package.json +79 -75
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure editing operations on the prompt's text and cursor (a UTF-16 index into `value`),
|
|
3
|
+
* so PromptInput.tsx only maps keys to them. The value may hold line breaks ("\n"): a
|
|
4
|
+
* multi-line prompt.
|
|
5
|
+
*/
|
|
6
|
+
import { t } from "../../core/messages/index.js";
|
|
1
7
|
export function insert({ value, cursor }, text) {
|
|
2
8
|
return { value: value.slice(0, cursor) + text + value.slice(cursor), cursor: cursor + text.length };
|
|
3
9
|
}
|
|
@@ -91,10 +97,12 @@ export function continueLine(state) {
|
|
|
91
97
|
return null;
|
|
92
98
|
return insert(deleteBack(state), "\n");
|
|
93
99
|
}
|
|
94
|
-
|
|
100
|
+
// A paste token in any language ("[Pasted text #1 +2 lines]", "[Texto pegado #1 +2 líneas]"...).
|
|
101
|
+
const PASTE_TOKEN = /\[[^\]\n#]+#(\d+) \+\d+ [^\]\n]+\]/g;
|
|
102
|
+
export const PASTE_TOKEN_BEFORE = /\[[^\]\n#]+#\d+ \+\d+ [^\]\n]+\]$/;
|
|
103
|
+
export const PASTE_TOKEN_AFTER = /^\[[^\]\n#]+#\d+ \+\d+ [^\]\n]+\]/;
|
|
95
104
|
function pasteToken(id, text) {
|
|
96
|
-
|
|
97
|
-
return `[Pasted text #${id} +${extra} ${extra === 1 ? "line" : "lines"}]`;
|
|
105
|
+
return t().pastedText(id, text.split("\n").length - 1);
|
|
98
106
|
}
|
|
99
107
|
/** Normalizes a pasted chunk's line breaks (terminals send "\r" or "\r\n"). */
|
|
100
108
|
export function normalizePaste(text) {
|
|
@@ -2,10 +2,12 @@ import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
|
|
2
2
|
import type { Mode } from "../../core/agentSpec.js";
|
|
3
3
|
import { type ModeControl } from "../../core/session.js";
|
|
4
4
|
import { type ChatTuiOptions } from "../chatTui.js";
|
|
5
|
+
import { type SessionOpener } from "../runs.js";
|
|
5
6
|
import { type RenderApproval } from "./SessionView.js";
|
|
6
7
|
import type { ToolPhrase } from "./toolGroup.js";
|
|
7
8
|
import { type HeaderInfo } from "./header.js";
|
|
8
9
|
export type { HeaderInfo } from "./header.js";
|
|
10
|
+
/** Options of `runChatInk()`: the chat's options plus the Ink-only ones. */
|
|
9
11
|
export interface InkChatOptions extends ChatTuiOptions {
|
|
10
12
|
/**
|
|
11
13
|
* Title, fields and an optional logo for the top of the chat: fixed at the top in full
|
|
@@ -46,7 +48,8 @@ export interface InkChatOptions extends ChatTuiOptions {
|
|
|
46
48
|
/**
|
|
47
49
|
* The session's mode control (`buildSessionOptions()` returns it): the status bar shows the
|
|
48
50
|
* current mode, and Shift+Tab switches between the modes it allows ("guided" and
|
|
49
|
-
* "interactive"; an autonomous session can't switch).
|
|
51
|
+
* "interactive"; an autonomous session can't switch). With a session opener, the one it
|
|
52
|
+
* returns is used instead.
|
|
50
53
|
*/
|
|
51
54
|
modeControl?: ModeControl;
|
|
52
55
|
/**
|
|
@@ -55,6 +58,16 @@ export interface InkChatOptions extends ChatTuiOptions {
|
|
|
55
58
|
*/
|
|
56
59
|
firstPromptSuggestion?: string;
|
|
57
60
|
}
|
|
61
|
+
/** A choice asked in place of the prompt (the /resume list). */
|
|
62
|
+
export interface ChatPicker {
|
|
63
|
+
title: string;
|
|
64
|
+
options: {
|
|
65
|
+
label: string;
|
|
66
|
+
value: string;
|
|
67
|
+
}[];
|
|
68
|
+
/** Settles it with the chosen value, or null if cancelled. */
|
|
69
|
+
resolve(value: string | null): void;
|
|
70
|
+
}
|
|
58
71
|
export interface ChatInputSnapshot {
|
|
59
72
|
/** The chat loop is waiting for a line (no turn in flight). */
|
|
60
73
|
prompting: boolean;
|
|
@@ -68,6 +81,8 @@ export interface ChatInputSnapshot {
|
|
|
68
81
|
files: string[];
|
|
69
82
|
/** The current mode, when a mode control is given. */
|
|
70
83
|
mode: Mode | null;
|
|
84
|
+
/** A choice shown in place of the prompt, if any. */
|
|
85
|
+
picker: ChatPicker | null;
|
|
71
86
|
closed: boolean;
|
|
72
87
|
}
|
|
73
88
|
/**
|
|
@@ -85,6 +100,11 @@ export declare function createChatInput(): {
|
|
|
85
100
|
setSuggestion: (suggestion: string | null) => void;
|
|
86
101
|
setFiles: (files: string[]) => void;
|
|
87
102
|
setMode: (mode: Mode | null) => void;
|
|
103
|
+
/** Shows a choice in place of the prompt; resolves with the value chosen, or null if cancelled. */
|
|
104
|
+
pick(title: string, options: {
|
|
105
|
+
label: string;
|
|
106
|
+
value: string;
|
|
107
|
+
}[]): Promise<string | null>;
|
|
88
108
|
/** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
|
|
89
109
|
unqueueLast(): string | null;
|
|
90
110
|
close: () => void;
|
|
@@ -98,6 +118,10 @@ export declare function createChatInput(): {
|
|
|
98
118
|
*
|
|
99
119
|
* With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
|
|
100
120
|
*
|
|
121
|
+
* Given a session opener instead of options, and `runsDir`, each run keeps its conversation
|
|
122
|
+
* in its own folder and can be resumed: `--continue` on the command line starts with the
|
|
123
|
+
* latest, and `/resume` picks one (see `ChatTuiOptions.runsDir`).
|
|
124
|
+
*
|
|
101
125
|
* Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
|
|
102
126
|
*/
|
|
103
|
-
export declare function runChatInk(options: Options, tuiOptions?: InkChatOptions): Promise<void>;
|
|
127
|
+
export declare function runChatInk(options: Options | SessionOpener, tuiOptions?: InkChatOptions): Promise<void>;
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createWriteStream } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { stdin, stdout } from "node:process";
|
|
4
5
|
import { useSyncExternalStore } from "react";
|
|
5
6
|
import { Box, render, Text, useInput } from "ink";
|
|
7
|
+
import { Select } from "@inkjs/ui";
|
|
6
8
|
import { getInteractionPort, setInteractionPort } from "../../core/interaction.js";
|
|
7
9
|
import { createInputQueue } from "../../core/session.js";
|
|
8
10
|
import { runQuery } from "../../core/runner.js";
|
|
11
|
+
import { listRuns, readConversation } from "../../core/runs.js";
|
|
9
12
|
import { capHistory, loadHistory, runChatTui, saveHistory, slashCommandToken } from "../chatTui.js";
|
|
10
13
|
import * as ui from "../ui.js";
|
|
14
|
+
import { t } from "../../core/messages/index.js";
|
|
15
|
+
import { applyLanguage } from "../language.js";
|
|
16
|
+
import { applyTheme, inkColor } from "../theme.js";
|
|
17
|
+
import { KitTheme } from "./inkTheme.js";
|
|
18
|
+
import { firstRun, openSession, runDate, runFolderOf, runLabel } from "../runs.js";
|
|
11
19
|
import { createInkInteraction } from "./inkInteraction.js";
|
|
12
20
|
import { PromptInput } from "./PromptInput.js";
|
|
13
21
|
import { createSessionModel, liveWidth } from "./sessionModel.js";
|
|
14
22
|
import { SessionView } from "./SessionView.js";
|
|
15
|
-
import { stripAnsi } from "./lineBuffer.js";
|
|
23
|
+
import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
16
24
|
import { clipboardSequence, enterFullscreen } from "./fullscreen.js";
|
|
17
25
|
import { createCursorController, CursorContext } from "./terminalCursor.js";
|
|
18
26
|
import { createTerminalStatus, focusFromReport } from "./terminalStatus.js";
|
|
@@ -30,7 +38,7 @@ const PROMPT_FRAME_COLUMNS = 4;
|
|
|
30
38
|
*/
|
|
31
39
|
export function createChatInput() {
|
|
32
40
|
const listeners = new Set();
|
|
33
|
-
let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, closed: false };
|
|
41
|
+
let snapshot = { prompting: false, history: [], commands: [], queued: [], suggestion: null, files: [], mode: null, picker: null, closed: false };
|
|
34
42
|
let pending = null;
|
|
35
43
|
let exited = false;
|
|
36
44
|
function update(changes) {
|
|
@@ -74,6 +82,21 @@ export function createChatInput() {
|
|
|
74
82
|
setSuggestion: (suggestion) => update({ suggestion }),
|
|
75
83
|
setFiles: (files) => update({ files }),
|
|
76
84
|
setMode: (mode) => update({ mode }),
|
|
85
|
+
/** Shows a choice in place of the prompt; resolves with the value chosen, or null if cancelled. */
|
|
86
|
+
pick(title, options) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
update({
|
|
89
|
+
picker: {
|
|
90
|
+
title,
|
|
91
|
+
options,
|
|
92
|
+
resolve(value) {
|
|
93
|
+
update({ picker: null });
|
|
94
|
+
resolve(value);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
},
|
|
77
100
|
/** Takes the last queued line back out of the queue (to edit it in the prompt), or null. */
|
|
78
101
|
unqueueLast() {
|
|
79
102
|
const last = snapshot.queued.at(-1);
|
|
@@ -85,10 +108,21 @@ export function createChatInput() {
|
|
|
85
108
|
close: () => update({ prompting: false, closed: true }),
|
|
86
109
|
};
|
|
87
110
|
}
|
|
88
|
-
|
|
111
|
+
/** The /resume list, in place of the prompt: ↑/↓ and Enter choose, Esc cancels. */
|
|
112
|
+
function PickerPanel({ picker }) {
|
|
113
|
+
const { columns } = stdout;
|
|
114
|
+
useInput((_text, key) => {
|
|
115
|
+
if (key.escape)
|
|
116
|
+
picker.resolve(null);
|
|
117
|
+
});
|
|
118
|
+
const width = liveWidth(columns) - PROMPT_FRAME_COLUMNS;
|
|
119
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: inkColor("border"), paddingX: 1, children: [_jsx(Text, { bold: true, children: picker.title }), _jsx(Select, { options: picker.options.map((option) => ({ ...option, label: fitWidth(option.label, Math.max(10, width - 2)) })), visibleOptionCount: Math.min(8, picker.options.length), onChange: (value) => picker.resolve(value) }), _jsx(Text, { dimColor: true, children: t().resumeHint })] }));
|
|
120
|
+
}
|
|
121
|
+
function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode, fullscreen, header, onFocusChange, modeControl: currentModeControl, onInterrupt }) {
|
|
89
122
|
const chat = useSyncExternalStore(input.subscribe, input.getSnapshot);
|
|
90
123
|
const session = useSyncExternalStore(model.subscribe, model.getSnapshot);
|
|
91
124
|
const checkpoint = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
|
125
|
+
const modeControl = currentModeControl();
|
|
92
126
|
// Ctrl+C stops what is running (a checkpoint, then the turn) and leaves only when idle;
|
|
93
127
|
// Esc interrupts the turn but never answers a checkpoint or leaves.
|
|
94
128
|
useInput((text, key) => {
|
|
@@ -104,7 +138,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
104
138
|
if (key.shift && key.tab) {
|
|
105
139
|
// Shift+Tab: the next mode this session allows.
|
|
106
140
|
if (!modeControl || modeControl.switchable.length < 2) {
|
|
107
|
-
model.note(ui.dim(
|
|
141
|
+
model.note(ui.dim(t().modeLocked(modeControl?.mode ?? mode)), "notice");
|
|
108
142
|
return;
|
|
109
143
|
}
|
|
110
144
|
const next = modeControl.switchable[(modeControl.switchable.indexOf(modeControl.mode) + 1) % modeControl.switchable.length];
|
|
@@ -112,6 +146,10 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
112
146
|
input.setMode(next);
|
|
113
147
|
return;
|
|
114
148
|
}
|
|
149
|
+
if (key.ctrl && text === "c" && chat.picker) {
|
|
150
|
+
chat.picker.resolve(null);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
115
153
|
if (key.ctrl && text === "c") {
|
|
116
154
|
if (checkpoint)
|
|
117
155
|
checkpoint.answer(checkpoint.kind === "decision" ? "q" : "");
|
|
@@ -124,7 +162,7 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
124
162
|
onInterrupt();
|
|
125
163
|
}
|
|
126
164
|
});
|
|
127
|
-
return (_jsx(SessionView, { model: model, interaction: interaction, renderApproval: renderApproval, mode: chat.mode ?? mode, modeSwitchable: (modeControl?.switchable.length ?? 0) > 1, closed: chat.closed, fullscreen: fullscreen, header: header, onCopy: fullscreen ? (text) => void stdout.write(clipboardSequence(text)) : undefined, children: _jsxs(Box, { flexDirection: "column", children: [chat.queued.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: ui.dim(`
|
|
165
|
+
return (_jsx(SessionView, { model: model, interaction: interaction, renderApproval: renderApproval, mode: chat.mode ?? mode, modeSwitchable: (modeControl?.switchable.length ?? 0) > 1, closed: chat.closed, fullscreen: fullscreen, header: header, onCopy: fullscreen ? (text) => void stdout.write(clipboardSequence(text)) : undefined, children: _jsxs(Box, { flexDirection: "column", children: [chat.queued.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: ui.dim(` ${t().queued(line)}`) }, index))), chat.picker ? (_jsx(PickerPanel, { picker: chat.picker })) : (_jsx(Box, { borderStyle: "round", borderColor: inkColor("border"), paddingX: 1, children: _jsx(PromptInput, { label: promptLabel.replace(/^\n+/, ""), history: chat.history, commands: chat.commands, inset: PROMPT_FRAME_COLUMNS, suggestion: chat.suggestion, onSubmit: (line) => input.submit(line), onRecallQueued: () => input.unqueueLast(), files: chat.files, onExit: () => input.submit(null) }) }))] }) }));
|
|
128
166
|
}
|
|
129
167
|
/**
|
|
130
168
|
* The Ink counterpart of `runChatTui()`, with the same options, session log and history
|
|
@@ -135,19 +173,39 @@ function ChatApp({ model, interaction, input, promptLabel, renderApproval, mode,
|
|
|
135
173
|
*
|
|
136
174
|
* With `fullscreen` it takes the whole terminal instead (see `InkChatOptions.fullscreen`).
|
|
137
175
|
*
|
|
176
|
+
* Given a session opener instead of options, and `runsDir`, each run keeps its conversation
|
|
177
|
+
* in its own folder and can be resumed: `--continue` on the command line starts with the
|
|
178
|
+
* latest, and `/resume` picks one (see `ChatTuiOptions.runsDir`).
|
|
179
|
+
*
|
|
138
180
|
* Without a TTY (piped, background) or with `plain`, it is `runChatTui()` unchanged.
|
|
139
181
|
*/
|
|
140
182
|
export async function runChatInk(options, tuiOptions = {}) {
|
|
141
183
|
if (tuiOptions.plain || !stdin.isTTY || !stdout.isTTY)
|
|
142
184
|
return await runChatTui(options, tuiOptions);
|
|
185
|
+
// Warnings about an unsupported language code go into the chat, not behind the full screen.
|
|
186
|
+
const languageWarnings = [];
|
|
187
|
+
applyLanguage(tuiOptions.language, (line) => languageWarnings.push(line));
|
|
188
|
+
applyTheme(tuiOptions.theme);
|
|
189
|
+
const opener = typeof options === "function" ? options : null;
|
|
190
|
+
const runsDir = opener ? tuiOptions.runsDir : undefined;
|
|
191
|
+
if (opener && !runsDir)
|
|
192
|
+
throw new Error("runChatInk(): a session opener needs `runsDir`.");
|
|
143
193
|
const exitCommands = new Set((tuiOptions.exitCommands ?? DEFAULT_EXIT_COMMANDS).map((c) => c.toLowerCase()));
|
|
144
194
|
const promptLabel = tuiOptions.promptLabel ?? DEFAULT_PROMPT_LABEL;
|
|
195
|
+
const userLine = (text) => `${promptLabel.replace(/^\n+/, "")}${text}`;
|
|
145
196
|
const historyLimit = tuiOptions.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
146
197
|
let historyEntries = tuiOptions.historyPath ? capHistory(await loadHistory(tuiOptions.historyPath), historyLimit) : [];
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
198
|
+
// The session in use: with runs, the run's folder, rebuilt on /resume.
|
|
199
|
+
let current = opener && runsDir ? await openSession(opener, await firstRun(runsDir)) : { options: options, run: null };
|
|
200
|
+
const modeControl = () => current.modeControl ?? tuiOptions.modeControl;
|
|
201
|
+
// With runs, the session log is the run's own; it moves with /resume.
|
|
202
|
+
let sessionLog = null;
|
|
203
|
+
function openLog() {
|
|
204
|
+
sessionLog?.end();
|
|
205
|
+
const file = current.run ? path.join(current.run.dir, "session.log") : tuiOptions.sessionLogPath;
|
|
206
|
+
sessionLog = file ? createWriteStream(file, { flags: "a" }) : null;
|
|
207
|
+
}
|
|
208
|
+
openLog();
|
|
151
209
|
const mirror = (text) => void sessionLog?.write(stripAnsi(text));
|
|
152
210
|
const model = createSessionModel({
|
|
153
211
|
formatAction: tuiOptions.formatAction,
|
|
@@ -160,29 +218,19 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
160
218
|
const input = createChatInput();
|
|
161
219
|
if (tuiOptions.firstPromptSuggestion)
|
|
162
220
|
input.setSuggestion(tuiOptions.firstPromptSuggestion);
|
|
163
|
-
|
|
164
|
-
input.setMode(tuiOptions.modeControl.mode);
|
|
165
|
-
void listProjectFiles(options.cwd ?? process.cwd()).then((files) => input.setFiles(files));
|
|
166
|
-
// Every registered command name and alias, built-ins included, for completion and for
|
|
167
|
-
// catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
|
|
168
|
-
const knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
|
|
169
|
-
void knownCommands.then((names) => {
|
|
170
|
-
const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
|
|
171
|
-
const local = tuiOptions.terminalIntegration === false ? [] : ["copy"];
|
|
172
|
-
input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
|
|
173
|
-
});
|
|
221
|
+
void listProjectFiles(current.options.cwd ?? process.cwd()).then((files) => input.setFiles(files));
|
|
174
222
|
let turnInterrupted = false;
|
|
175
223
|
function interruptTurn() {
|
|
176
224
|
if (turnInterrupted)
|
|
177
225
|
return;
|
|
178
226
|
turnInterrupted = true;
|
|
179
227
|
void run.interrupt();
|
|
180
|
-
model.writeLine(ui.warn(
|
|
228
|
+
model.writeLine(ui.warn(t().interrupted));
|
|
181
229
|
}
|
|
182
|
-
// One reader
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
230
|
+
// One reader per session instead of drainTurn()'s one per turn: the prompt suggestion
|
|
231
|
+
// arrives after its turn's turn-end, while the human is already at the prompt, and MCP
|
|
232
|
+
// errors arrive before the first turn. Still manual .next() calls, never a for-await that
|
|
233
|
+
// could be broken out of (ADR-010). A reader left behind by /resume stops reporting.
|
|
186
234
|
let turnDone = null;
|
|
187
235
|
let readError = null;
|
|
188
236
|
function finishTurn() {
|
|
@@ -190,26 +238,48 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
190
238
|
turnDone = null;
|
|
191
239
|
done?.();
|
|
192
240
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
241
|
+
let queue = createInputQueue();
|
|
242
|
+
let run;
|
|
243
|
+
let knownCommands;
|
|
244
|
+
let generation = 0;
|
|
245
|
+
function connect() {
|
|
246
|
+
const own = ++generation;
|
|
247
|
+
queue = createInputQueue();
|
|
248
|
+
run = runQuery(queue.iterable, { ...current.options, promptSuggestions: tuiOptions.promptSuggestions ?? true });
|
|
249
|
+
const events = run.events[Symbol.asyncIterator]();
|
|
250
|
+
input.setMode(modeControl()?.mode ?? null);
|
|
251
|
+
// Every registered command name and alias, built-ins included, for completion and for
|
|
252
|
+
// catching an unknown "/command" before it reaches the model as plain text (see chatTui.ts).
|
|
253
|
+
knownCommands = run.supportedCommands().then((commands) => new Set(commands.flatMap((c) => [c.name, ...(c.aliases ?? [])])), () => null);
|
|
254
|
+
void knownCommands.then((names) => {
|
|
255
|
+
const exits = [...exitCommands].filter((c) => c.startsWith("/")).map((c) => c.slice(1));
|
|
256
|
+
const local = [...(tuiOptions.terminalIntegration === false ? [] : ["copy"]), ...(runsDir ? ["resume"] : [])];
|
|
257
|
+
input.setCommands([...new Set([...(names ?? []), ...exits, ...local])].sort());
|
|
258
|
+
});
|
|
259
|
+
void (async () => {
|
|
260
|
+
try {
|
|
261
|
+
while (true) {
|
|
262
|
+
const { value, done } = await events.next();
|
|
263
|
+
if (own !== generation || done)
|
|
264
|
+
break;
|
|
265
|
+
if (value.type === "prompt-suggestion") {
|
|
266
|
+
input.setSuggestion(value.suggestion);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
model.render(value);
|
|
270
|
+
if (value.type === "turn-end")
|
|
271
|
+
finishTurn();
|
|
202
272
|
}
|
|
203
|
-
model.render(value);
|
|
204
|
-
if (value.type === "turn-end")
|
|
205
|
-
finishTurn();
|
|
206
273
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (own === generation)
|
|
276
|
+
readError = error;
|
|
277
|
+
}
|
|
278
|
+
if (own === generation)
|
|
279
|
+
finishTurn();
|
|
280
|
+
})();
|
|
281
|
+
}
|
|
282
|
+
connect();
|
|
213
283
|
async function runTurn(line) {
|
|
214
284
|
input.setSuggestion(null);
|
|
215
285
|
status?.turnStarted();
|
|
@@ -225,22 +295,59 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
225
295
|
if (readError)
|
|
226
296
|
throw readError;
|
|
227
297
|
}
|
|
298
|
+
/** Draws the run's earlier conversation (when it has one) and says it was resumed. */
|
|
299
|
+
async function showConversation(folder, fromResume) {
|
|
300
|
+
if (!folder.sessionId)
|
|
301
|
+
return;
|
|
302
|
+
const messages = await readConversation(folder.dir);
|
|
303
|
+
const [summary] = (await listRuns(path.dirname(folder.dir))).filter((r) => path.resolve(r.dir) === path.resolve(folder.dir));
|
|
304
|
+
// In full screen the history is redrawn from the start; inline, the terminal's
|
|
305
|
+
// scrollback can't be taken back, so the conversation follows what's already there.
|
|
306
|
+
if (fromResume && tuiOptions.fullscreen)
|
|
307
|
+
model.reset();
|
|
308
|
+
model.replay(messages, userLine);
|
|
309
|
+
model.writeLine(ui.dim(t().resumed(runDate(summary?.updatedAt ?? new Date()))));
|
|
310
|
+
}
|
|
311
|
+
/** /resume: picks one of the runs and switches the session to it. */
|
|
312
|
+
async function resume() {
|
|
313
|
+
if (!opener || !runsDir)
|
|
314
|
+
return;
|
|
315
|
+
const runs = await listRuns(runsDir);
|
|
316
|
+
if (runs.length === 0) {
|
|
317
|
+
model.writeLine(ui.dim(t().noEarlierRuns));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const chosen = await input.pick(t().resumeTitle, runs.map((summary, i) => ({ label: `${i + 1}. ${runLabel(summary, current.run ?? undefined)}`, value: summary.dir })));
|
|
321
|
+
const summary = runs.find((r) => r.dir === chosen);
|
|
322
|
+
if (!summary || (current.run && path.resolve(summary.dir) === path.resolve(current.run.dir)))
|
|
323
|
+
return;
|
|
324
|
+
queue.end();
|
|
325
|
+
run.close();
|
|
326
|
+
current = await openSession(opener, await runFolderOf(summary));
|
|
327
|
+
openLog();
|
|
328
|
+
connect();
|
|
329
|
+
await showConversation(current.run, true);
|
|
330
|
+
}
|
|
228
331
|
const previousPort = getInteractionPort();
|
|
229
332
|
setInteractionPort(interaction.port);
|
|
230
333
|
const restoreTerminal = tuiOptions.fullscreen ? enterFullscreen(stdout) : null;
|
|
231
334
|
const cursor = tuiOptions.fullscreen ? createCursorController(stdout) : null;
|
|
232
335
|
const status = tuiOptions.terminalIntegration === false ? null : createTerminalStatus((text) => void stdout.write(text), tuiOptions.header?.title ?? "agent");
|
|
233
336
|
status?.start();
|
|
234
|
-
const app = render(_jsx(CursorContext.Provider, { value: cursor, children: _jsx(ChatApp, { model: model, interaction: interaction, input: input, promptLabel: promptLabel, renderApproval: tuiOptions.renderApproval, mode: tuiOptions.mode, fullscreen: tuiOptions.fullscreen, header: tuiOptions.header && tuiOptions.fullscreen ? headerLines(tuiOptions.header) : undefined, onFocusChange: status ? (focused) => status.setFocused(focused) : undefined, modeControl:
|
|
337
|
+
const app = render(_jsx(CursorContext.Provider, { value: cursor, children: _jsx(KitTheme, { children: _jsx(ChatApp, { model: model, interaction: interaction, input: input, promptLabel: promptLabel, renderApproval: tuiOptions.renderApproval, mode: tuiOptions.mode, fullscreen: tuiOptions.fullscreen, header: tuiOptions.header && tuiOptions.fullscreen ? headerLines(tuiOptions.header) : undefined, onFocusChange: status ? (focused) => status.setFocused(focused) : undefined, modeControl: modeControl, onInterrupt: interruptTurn }) }) }),
|
|
235
338
|
// In full screen Ink writes through the cursor controller, which places the terminal's cursor.
|
|
236
339
|
{ exitOnCtrlC: false, ...(cursor ? { stdout: cursor.stream } : {}) });
|
|
237
340
|
try {
|
|
238
341
|
// In full screen the header is pinned above the history (see SessionView) instead.
|
|
239
342
|
if (tuiOptions.header && !tuiOptions.fullscreen)
|
|
240
343
|
model.note(headerLines(tuiOptions.header).join("\n"));
|
|
344
|
+
for (const warning of languageWarnings)
|
|
345
|
+
model.writeLine(ui.warn(warning));
|
|
241
346
|
if (tuiOptions.welcomeMessage)
|
|
242
347
|
model.writeLine(tuiOptions.welcomeMessage);
|
|
243
|
-
if (
|
|
348
|
+
if (current.run)
|
|
349
|
+
await showConversation(current.run, false);
|
|
350
|
+
if (tuiOptions.initialPrompt && !current.run?.sessionId) {
|
|
244
351
|
// Logged but not shown, like runChatTui(): it is the agent's cue, not something typed.
|
|
245
352
|
mirror(`${promptLabel}${tuiOptions.initialPrompt}\n`);
|
|
246
353
|
await runTurn(tuiOptions.initialPrompt);
|
|
@@ -256,7 +363,7 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
256
363
|
if (!line)
|
|
257
364
|
continue;
|
|
258
365
|
mirror(`${promptLabel}${line}\n`);
|
|
259
|
-
model.note(
|
|
366
|
+
model.note(userLine(line), "user");
|
|
260
367
|
if (tuiOptions.historyPath) {
|
|
261
368
|
historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
|
|
262
369
|
await saveHistory(tuiOptions.historyPath, historyEntries);
|
|
@@ -268,14 +375,18 @@ export async function runChatInk(options, tuiOptions = {}) {
|
|
|
268
375
|
const reply = model.lastReply();
|
|
269
376
|
if (reply)
|
|
270
377
|
stdout.write(clipboardSequence(reply));
|
|
271
|
-
model.writeLine(ui.dim(reply ?
|
|
378
|
+
model.writeLine(ui.dim(reply ? t().copiedReply(reply.length) : t().nothingToCopy));
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (runsDir && line.toLowerCase() === "/resume") {
|
|
382
|
+
await resume();
|
|
272
383
|
continue;
|
|
273
384
|
}
|
|
274
385
|
const commandToken = slashCommandToken(line);
|
|
275
386
|
if (commandToken) {
|
|
276
387
|
const names = await knownCommands;
|
|
277
388
|
if (names && !names.has(commandToken)) {
|
|
278
|
-
model.writeLine(ui.warn(
|
|
389
|
+
model.writeLine(ui.warn(t().unknownCommand(commandToken)));
|
|
279
390
|
continue;
|
|
280
391
|
}
|
|
281
392
|
}
|
|
@@ -71,6 +71,16 @@ export interface SessionModel {
|
|
|
71
71
|
/** The agent's text in the latest turn, plain (for /copy). */
|
|
72
72
|
lastReply(): string;
|
|
73
73
|
setContextPercent(percent: number | null): void;
|
|
74
|
+
/** Empties the history and the counters, for a resumed conversation redrawn from the start. */
|
|
75
|
+
reset(): void;
|
|
76
|
+
/**
|
|
77
|
+
* Draws an earlier conversation (a resumed one): the human's lines as `userLine()` makes
|
|
78
|
+
* them and the agent's replies with their markdown, without passing them to `onWrite`.
|
|
79
|
+
*/
|
|
80
|
+
replay(messages: readonly {
|
|
81
|
+
role: "user" | "assistant";
|
|
82
|
+
text: string;
|
|
83
|
+
}[], userLine: (text: string) => string): void;
|
|
74
84
|
}
|
|
75
85
|
/**
|
|
76
86
|
* The state behind the Ink views, in the look of the Claude Code CLI: the agent's words
|
|
@@ -4,6 +4,7 @@ import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
|
4
4
|
import { finishedLength, renderMarkdown } from "./markdown.js";
|
|
5
5
|
import { toolGroupExpanded, toolGroupSummary } from "./toolGroup.js";
|
|
6
6
|
import * as ui from "../ui.js";
|
|
7
|
+
import { t } from "../../core/messages/index.js";
|
|
7
8
|
/**
|
|
8
9
|
* Columns the live area may use: short of the edge, where the Windows console wraps early
|
|
9
10
|
* (see lineBuffer.ts). Read on every use, so a resized terminal is picked up.
|
|
@@ -196,7 +197,7 @@ export function createSessionModel(options = {}) {
|
|
|
196
197
|
}
|
|
197
198
|
case "mcp-error":
|
|
198
199
|
closeAll();
|
|
199
|
-
push([ui.warn(
|
|
200
|
+
push([ui.warn(t().mcpFailed(event.failedServers.join(", ")))], "notice");
|
|
200
201
|
refreshLive();
|
|
201
202
|
return;
|
|
202
203
|
case "info":
|
|
@@ -242,7 +243,7 @@ export function createSessionModel(options = {}) {
|
|
|
242
243
|
const started = snapshot.turnStartedAt;
|
|
243
244
|
if (started !== null) {
|
|
244
245
|
const seconds = Math.max(1, Math.round((Date.now() - started) / 1000));
|
|
245
|
-
push([ui.dim(`✻
|
|
246
|
+
push([ui.dim(`✻ ${t().workedFor(seconds)}`)], "note");
|
|
246
247
|
}
|
|
247
248
|
snapshot = { ...snapshot, busy: false, turnStartedAt: null, activity: null, subagentActivity: null };
|
|
248
249
|
refreshLive();
|
|
@@ -258,5 +259,29 @@ export function createSessionModel(options = {}) {
|
|
|
258
259
|
getSnapshot: () => snapshot,
|
|
259
260
|
lastReply: () => reply.trim(),
|
|
260
261
|
setContextPercent: (percent) => update({ contextPercent: percent }),
|
|
262
|
+
reset() {
|
|
263
|
+
closeAll();
|
|
264
|
+
lastKind = null;
|
|
265
|
+
gapPending = false;
|
|
266
|
+
reply = "";
|
|
267
|
+
snapshot = { ...snapshot, items: [], live: "", liveGap: false, turns: 0, usage: null, contextPercent: null };
|
|
268
|
+
refreshLive();
|
|
269
|
+
},
|
|
270
|
+
replay(messages, userLine) {
|
|
271
|
+
closeAll();
|
|
272
|
+
for (const message of messages) {
|
|
273
|
+
if (!lastIsBlank(snapshot.items))
|
|
274
|
+
gapPending = true;
|
|
275
|
+
if (message.role === "user") {
|
|
276
|
+
push(userLine(message.text).split("\n"), "user");
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
segment = message.text;
|
|
280
|
+
flushSegment(true);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
snapshot = { ...snapshot, turns: snapshot.turns + messages.filter((message) => message.role === "user").length };
|
|
284
|
+
refreshLive();
|
|
285
|
+
},
|
|
261
286
|
};
|
|
262
287
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ToolPhrase } from "../../core/messages/index.js";
|
|
1
2
|
export interface ToolCall {
|
|
2
3
|
id?: string;
|
|
3
4
|
toolName: string;
|
|
@@ -11,7 +12,7 @@ export interface ToolCall {
|
|
|
11
12
|
children?: string[];
|
|
12
13
|
}
|
|
13
14
|
/** 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
|
|
15
|
+
export type { ToolPhrase };
|
|
15
16
|
/**
|
|
16
17
|
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
17
18
|
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
@@ -1,24 +1,11 @@
|
|
|
1
|
-
import { fitWidth } from "./lineBuffer.js";
|
|
1
|
+
import { fitWidth, stripAnsi } from "./lineBuffer.js";
|
|
2
2
|
import { renderMarkdown } from "./markdown.js";
|
|
3
3
|
import * as ui from "../ui.js";
|
|
4
|
+
import { t } from "../../core/messages/index.js";
|
|
4
5
|
// A subagent can make many calls; the latest ones are shown, the rest counted.
|
|
5
6
|
const MAX_CHILDREN = 5;
|
|
6
7
|
// Tools whose result is a subagent's answer, in markdown: its first line is rendered.
|
|
7
8
|
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
9
|
/**
|
|
23
10
|
* A folded group's one line, as in the Claude Code CLI: "Read 2 files, ran 1 shell
|
|
24
11
|
* command". Calls to the same kind of tool are counted together, in the order they first
|
|
@@ -29,7 +16,7 @@ export function toolGroupSummary(calls, phraseFor) {
|
|
|
29
16
|
// Counted by the phrase's text: a consumer's phraseFor() may return a new array each call.
|
|
30
17
|
const counts = new Map();
|
|
31
18
|
for (const call of calls) {
|
|
32
|
-
const phrase = phraseFor?.(call.toolName) ??
|
|
19
|
+
const phrase = phraseFor?.(call.toolName) ?? t().toolPhrases[call.toolName] ?? t().otherTools;
|
|
33
20
|
const key = phrase.join("|");
|
|
34
21
|
const entry = counts.get(key) ?? { phrase, n: 0 };
|
|
35
22
|
entry.n++;
|
|
@@ -46,13 +33,15 @@ export function toolGroupSummary(calls, phraseFor) {
|
|
|
46
33
|
export function resultSummary(result, markdown = false) {
|
|
47
34
|
const lines = result.text.split("\n").filter((line) => line.trim() !== "");
|
|
48
35
|
if (lines.length === 0)
|
|
49
|
-
return result.isError ? ui.error(
|
|
50
|
-
const more = lines.length > 1 ? ui.dim(` (
|
|
36
|
+
return result.isError ? ui.error(t().error) : ui.dim(t().noOutput);
|
|
37
|
+
const more = lines.length > 1 ? ui.dim(` ${t().moreResultLines(lines.length - 1)}`) : "";
|
|
51
38
|
const first = lines[0].trim();
|
|
52
39
|
if (result.isError)
|
|
53
40
|
return ui.error(first) + more;
|
|
54
41
|
// Rendered wide, as one row: the summary line is cut to the room left afterwards.
|
|
55
|
-
|
|
42
|
+
// Plain before the theme's color: a style the markdown opened and closed (bold's reset)
|
|
43
|
+
// would cancel a dim around it.
|
|
44
|
+
return ui.toolResult(markdown ? stripAnsi(renderMarkdown(first, 1000)[0] ?? first) : first) + more;
|
|
56
45
|
}
|
|
57
46
|
/**
|
|
58
47
|
* The calls one by one: each behind a `●`, then after `⎿` the calls a subagent made (the
|
|
@@ -65,12 +54,12 @@ export function toolGroupExpanded(calls, width) {
|
|
|
65
54
|
const shown = children.slice(-MAX_CHILDREN);
|
|
66
55
|
const result = call.result ? fitWidth(resultSummary(call.result, SUBAGENT_TOOLS.has(call.toolName)), width - 5) : ui.dim("…");
|
|
67
56
|
const under = [
|
|
68
|
-
...(children.length > shown.length ? [ui.dim(
|
|
57
|
+
...(children.length > shown.length ? [ui.dim(t().earlierCalls(children.length - shown.length))] : []),
|
|
69
58
|
...shown.map((label) => fitWidth(ui.dim(`· ${label}`), width - 5)),
|
|
70
59
|
result,
|
|
71
60
|
];
|
|
72
61
|
return [
|
|
73
|
-
`${ui.toolBullet("●")} ${fitWidth(call.label, width - 2)}`,
|
|
62
|
+
`${ui.toolBullet("●")} ${fitWidth(ui.toolLabel(call.label), width - 2)}`,
|
|
74
63
|
...under.map((line, i) => (line === "" ? "" : ` ${i === 0 ? ui.dim("⎿") : " "} ${line}`)),
|
|
75
64
|
];
|
|
76
65
|
});
|