flint-agent 1.14.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/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- package/system.md +63 -0
package/src/ui/output.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Output formatting module
|
|
2
|
+
// Single source of truth for all chat output styling and indentation
|
|
3
|
+
//
|
|
4
|
+
// Hierarchy:
|
|
5
|
+
// USER " > message" (highlighted bg)
|
|
6
|
+
// AGENT " text" (normal, 2-space indent)
|
|
7
|
+
// TOOL " tool -> result" (dim, 4-space indent)
|
|
8
|
+
// CHILD " ✦ Agent@port:" (cyan, with box drawing)
|
|
9
|
+
// CHILD " │ response line" (cyan border)
|
|
10
|
+
// PROCESS " ┌ [bg:1] command" (dim, with box drawing)
|
|
11
|
+
// PROCESS " │ output line" (dim)
|
|
12
|
+
// PROCESS " └ [bg:1] exit 0" (dim)
|
|
13
|
+
// SYSTEM " italic system info" (gray)
|
|
14
|
+
// STATS " --- cost | session" (dim)
|
|
15
|
+
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import { processLedgerLine } from "./tool-ledger.js";
|
|
18
|
+
import { highlight as hlHighlight } from "cli-highlight";
|
|
19
|
+
import { createLogger } from "../logging/logger.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("ui-output");
|
|
22
|
+
|
|
23
|
+
let _store = null;
|
|
24
|
+
|
|
25
|
+
export function initOutput(store) {
|
|
26
|
+
log.debug("initOutput", { hasStore: !!store });
|
|
27
|
+
_store = store;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
31
|
+
|
|
32
|
+
function addLine(text) {
|
|
33
|
+
const plain = (text || "").replace(ANSI_RE, "").slice(0, 80);
|
|
34
|
+
log.debug("addLine", { len: (text || "").length, preview: plain });
|
|
35
|
+
_store.getState().addLine(text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Word-wrap text to fit terminal width, preserving indent on wrapped lines
|
|
39
|
+
function wrapLines(indent, text, cols) {
|
|
40
|
+
const maxWidth = cols - indent.length;
|
|
41
|
+
if (maxWidth <= 10) { addLine(indent + text); return; }
|
|
42
|
+
const plain = text.replace(ANSI_RE, "");
|
|
43
|
+
if (plain.length <= maxWidth) { addLine(indent + text); return; }
|
|
44
|
+
// Split on words, re-wrap
|
|
45
|
+
const words = text.split(" ");
|
|
46
|
+
let line = "";
|
|
47
|
+
let lineLen = 0;
|
|
48
|
+
for (const word of words) {
|
|
49
|
+
const wordLen = word.replace(ANSI_RE, "").length;
|
|
50
|
+
if (lineLen > 0 && lineLen + 1 + wordLen > maxWidth) {
|
|
51
|
+
addLine(indent + line);
|
|
52
|
+
line = word;
|
|
53
|
+
lineLen = wordLen;
|
|
54
|
+
} else {
|
|
55
|
+
line = lineLen > 0 ? line + " " + word : word;
|
|
56
|
+
lineLen = lineLen > 0 ? lineLen + 1 + wordLen : wordLen;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (line) addLine(indent + line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- User message (highlighted bar) ---
|
|
63
|
+
export function printUser(text) {
|
|
64
|
+
const cols = process.stdout.columns || 80;
|
|
65
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
66
|
+
const plain = text.replace(ANSI_RE, "");
|
|
67
|
+
const pad = Math.max(0, cols - plain.length - 2);
|
|
68
|
+
addLine("");
|
|
69
|
+
addLine(chalk.bgGray.whiteBright(` ${text}${" ".repeat(pad)} `));
|
|
70
|
+
addLine("");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Inline markdown rendering ---
|
|
74
|
+
function renderMarkdown(text) {
|
|
75
|
+
// Code blocks (``` ... ```) — already handled line-by-line in printAgent
|
|
76
|
+
// Inline code `text`
|
|
77
|
+
text = text.replace(/`([^`]+)`/g, (_, code) => chalk.cyan(code));
|
|
78
|
+
// Bold **text** or __text__
|
|
79
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, (_, b) => chalk.bold(b));
|
|
80
|
+
text = text.replace(/__([^_]+)__/g, (_, b) => chalk.bold(b));
|
|
81
|
+
// Italic *text* or _text_ (but not inside words like file_name)
|
|
82
|
+
text = text.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, (_, i) => chalk.italic(i));
|
|
83
|
+
text = text.replace(/(?<!\w)_([^_]+)_(?!\w)/g, (_, i) => chalk.italic(i));
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Track code block state across calls
|
|
88
|
+
let _inCodeBlock = false;
|
|
89
|
+
let _codeBlockLang = "";
|
|
90
|
+
let _codeBlockLines = [];
|
|
91
|
+
|
|
92
|
+
// Track markdown table buffering across calls
|
|
93
|
+
let _tableBuffer = [];
|
|
94
|
+
|
|
95
|
+
// Markdown table line: starts and ends with |, has at least one inner |
|
|
96
|
+
const TABLE_LINE_RE = /^\s*\|.+\|\s*$/;
|
|
97
|
+
// Separator line: | --- | --- | (with optional colons for alignment)
|
|
98
|
+
const TABLE_SEP_RE = /^\s*\|[\s:]*-{2,}[\s:]*\|/;
|
|
99
|
+
|
|
100
|
+
function flushTable() {
|
|
101
|
+
if (_tableBuffer.length < 2) {
|
|
102
|
+
// Not enough lines for a table — dump as plain text
|
|
103
|
+
for (const line of _tableBuffer) {
|
|
104
|
+
wrapLines(" ", renderMarkdown(line), process.stdout.columns || 80);
|
|
105
|
+
}
|
|
106
|
+
_tableBuffer = [];
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Find separator row
|
|
111
|
+
let sepIdx = _tableBuffer.findIndex((l) => TABLE_SEP_RE.test(l));
|
|
112
|
+
let headerRow, dataRows;
|
|
113
|
+
|
|
114
|
+
if (sepIdx === 1) {
|
|
115
|
+
// Standard: header, separator, data rows
|
|
116
|
+
headerRow = _tableBuffer[0];
|
|
117
|
+
dataRows = _tableBuffer.slice(2);
|
|
118
|
+
} else if (sepIdx === 0) {
|
|
119
|
+
// Separator first — treat first data row as header
|
|
120
|
+
headerRow = _tableBuffer[1] || "";
|
|
121
|
+
dataRows = _tableBuffer.slice(2);
|
|
122
|
+
} else {
|
|
123
|
+
// No separator — first row is header, rest are data
|
|
124
|
+
headerRow = _tableBuffer[0];
|
|
125
|
+
dataRows = _tableBuffer.slice(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parseRow = (line) =>
|
|
129
|
+
line.split("|").slice(1, -1).map((c) => c.trim());
|
|
130
|
+
|
|
131
|
+
const columns = parseRow(headerRow);
|
|
132
|
+
const rows = dataRows
|
|
133
|
+
.filter((l) => !TABLE_SEP_RE.test(l)) // skip any extra separators
|
|
134
|
+
.map(parseRow);
|
|
135
|
+
|
|
136
|
+
if (columns.length > 0 && rows.length > 0) {
|
|
137
|
+
_store.getState().addTable({ columns, rows });
|
|
138
|
+
} else {
|
|
139
|
+
// Fallback: dump as text
|
|
140
|
+
for (const line of _tableBuffer) {
|
|
141
|
+
wrapLines(" ", renderMarkdown(line), process.stdout.columns || 80);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_tableBuffer = [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Agent (main) response line ---
|
|
149
|
+
export function printAgent(text) {
|
|
150
|
+
const cols = process.stdout.columns || 80;
|
|
151
|
+
|
|
152
|
+
// Markdown table lines — buffer and flush as Table component
|
|
153
|
+
if (!_inCodeBlock && TABLE_LINE_RE.test(text)) {
|
|
154
|
+
_tableBuffer.push(text);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Non-table line after table buffer — flush table first
|
|
158
|
+
if (_tableBuffer.length > 0) {
|
|
159
|
+
flushTable();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Code block fence
|
|
163
|
+
if (/^```/.test(text)) {
|
|
164
|
+
if (!_inCodeBlock) {
|
|
165
|
+
_inCodeBlock = true;
|
|
166
|
+
_codeBlockLang = text.slice(3).trim();
|
|
167
|
+
_codeBlockLines = [];
|
|
168
|
+
return;
|
|
169
|
+
} else {
|
|
170
|
+
// Closing fence — flush buffered code with syntax highlighting
|
|
171
|
+
flushCodeBlock();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Inside code block — buffer lines for syntax highlighting
|
|
177
|
+
if (_inCodeBlock) {
|
|
178
|
+
_codeBlockLines.push(text);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Markdown headings
|
|
183
|
+
const headingMatch = text.match(/^(#{1,3})\s+(.+)$/);
|
|
184
|
+
if (headingMatch) {
|
|
185
|
+
const level = headingMatch[1].length;
|
|
186
|
+
const content = headingMatch[2];
|
|
187
|
+
if (level === 1) { addLine(""); addLine(" " + chalk.bold.underline(content)); addLine(""); }
|
|
188
|
+
else if (level === 2) { addLine(""); addLine(" " + chalk.bold(content)); }
|
|
189
|
+
else { addLine(" " + chalk.bold(content)); }
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Horizontal rule
|
|
194
|
+
if (/^[-*_]{3,}\s*$/.test(text)) {
|
|
195
|
+
addLine(" " + chalk.dim("-".repeat(Math.min(40, cols - 4))));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Unordered list items
|
|
200
|
+
const listMatch = text.match(/^(\s*)[*-]\s+(.+)$/);
|
|
201
|
+
if (listMatch) {
|
|
202
|
+
const depth = Math.floor(listMatch[1].length / 2);
|
|
203
|
+
const indent = " " + " ".repeat(depth);
|
|
204
|
+
wrapLines(indent, chalk.dim("-") + " " + renderMarkdown(listMatch[2]), cols);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Ordered list items
|
|
209
|
+
const olMatch = text.match(/^(\s*)\d+\.\s+(.+)$/);
|
|
210
|
+
if (olMatch) {
|
|
211
|
+
const depth = Math.floor(olMatch[1].length / 2);
|
|
212
|
+
const indent = " " + " ".repeat(depth);
|
|
213
|
+
wrapLines(indent, renderMarkdown(olMatch[2]), cols);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Regular text with inline markdown
|
|
218
|
+
wrapLines(" ", renderMarkdown(text), cols);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Flush buffered code block with syntax highlighting
|
|
222
|
+
function flushCodeBlock() {
|
|
223
|
+
const code = _codeBlockLines.join("\n");
|
|
224
|
+
const lang = _codeBlockLang || "";
|
|
225
|
+
_inCodeBlock = false;
|
|
226
|
+
_codeBlockLines = [];
|
|
227
|
+
_codeBlockLang = "";
|
|
228
|
+
|
|
229
|
+
addLine(" " + chalk.dim("─".repeat(3) + " " + (lang || "code") + " " + "─".repeat(Math.max(0, 40 - (lang || "code").length))));
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const opts = lang ? { language: lang, ignoreIllegals: true } : { ignoreIllegals: true };
|
|
233
|
+
const highlighted = hlHighlight(code, opts);
|
|
234
|
+
for (const line of highlighted.split("\n")) {
|
|
235
|
+
addLine(" " + line);
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Fallback to cyan if highlighting fails
|
|
239
|
+
for (const line of code.split("\n")) {
|
|
240
|
+
addLine(" " + chalk.cyan(line));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
addLine(" " + chalk.dim("─".repeat(44)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Flush any buffered state (call at end of agent response)
|
|
248
|
+
export function flushAgentState() {
|
|
249
|
+
log.debug("flushAgentState", { tableBufferLen: _tableBuffer.length, inCodeBlock: _inCodeBlock });
|
|
250
|
+
if (_tableBuffer.length > 0) flushTable();
|
|
251
|
+
if (_inCodeBlock && _codeBlockLines.length > 0) flushCodeBlock();
|
|
252
|
+
_inCodeBlock = false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- Agent streaming placeholder (shown during tool execution) ---
|
|
256
|
+
export function setAgentStream(text) {
|
|
257
|
+
const plain = (text || "").replace(ANSI_RE, "").slice(0, 60);
|
|
258
|
+
log.debug("setAgentStream", { len: (text || "").length, preview: plain });
|
|
259
|
+
_store.getState().setStreamText(text ? ` ${text}` : "");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Background process streaming (one updating line per process) ---
|
|
263
|
+
export function setProcessStream(procId, text) {
|
|
264
|
+
_store.getState().setProcessStream(procId, text ? ` ${chalk.dim("|")} ${chalk.dim(text)}` : "");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Tool result (compact, dim) ---
|
|
268
|
+
export function printTool(toolName, argsShort, resultShort) {
|
|
269
|
+
addLine(` ${chalk.dim(toolName)} ${chalk.dim(argsShort)} ${chalk.dim("->")} ${chalk.gray(resultShort)}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function printToolDenied(toolName, argsShort) {
|
|
273
|
+
addLine(` ${chalk.yellow(toolName)} ${chalk.dim(argsShort)} ${chalk.red("DENIED")}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
// --- Child agent response (box with cyan border) ---
|
|
278
|
+
export function printChildAgent(port, response, maxLines = 20, label) {
|
|
279
|
+
const name = label || `Agent@${port}`;
|
|
280
|
+
const lines = response.split("\n");
|
|
281
|
+
const cols = process.stdout.columns || 80;
|
|
282
|
+
addLine(` ${chalk.cyan("*")} ${chalk.cyan(`${name}:`)}`);
|
|
283
|
+
const show = lines.slice(0, maxLines);
|
|
284
|
+
const border = ` ${chalk.cyan("|")} `;
|
|
285
|
+
|
|
286
|
+
// Detect and render markdown tables within child response
|
|
287
|
+
let tableBuf = [];
|
|
288
|
+
const flushChildTable = () => {
|
|
289
|
+
if (tableBuf.length < 2) {
|
|
290
|
+
for (const tl of tableBuf) wrapLines(border, renderMarkdown(tl), cols);
|
|
291
|
+
tableBuf = [];
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const sepIdx = tableBuf.findIndex((l) => TABLE_SEP_RE.test(l));
|
|
295
|
+
const headerRow = sepIdx === 1 ? tableBuf[0] : tableBuf[0];
|
|
296
|
+
const dataStart = sepIdx >= 0 ? Math.max(sepIdx + 1, 2) : 1;
|
|
297
|
+
const parseRow = (l) => l.split("|").slice(1, -1).map((c) => c.trim());
|
|
298
|
+
const columns = parseRow(headerRow);
|
|
299
|
+
const rows = tableBuf.slice(dataStart).filter((l) => !TABLE_SEP_RE.test(l)).map(parseRow);
|
|
300
|
+
if (columns.length > 0 && rows.length > 0) {
|
|
301
|
+
_store.getState().addTable({ columns, rows });
|
|
302
|
+
} else {
|
|
303
|
+
for (const tl of tableBuf) wrapLines(border, renderMarkdown(tl), cols);
|
|
304
|
+
}
|
|
305
|
+
tableBuf = [];
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
for (const line of show) {
|
|
309
|
+
if (TABLE_LINE_RE.test(line)) {
|
|
310
|
+
tableBuf.push(line);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (tableBuf.length > 0) flushChildTable();
|
|
314
|
+
wrapLines(border, renderMarkdown(line), cols);
|
|
315
|
+
}
|
|
316
|
+
if (tableBuf.length > 0) flushChildTable();
|
|
317
|
+
|
|
318
|
+
if (lines.length > maxLines) {
|
|
319
|
+
addLine(` ${chalk.cyan("|")} ${chalk.dim(`... +${lines.length - maxLines} lines`)}`);
|
|
320
|
+
}
|
|
321
|
+
addLine(` ${chalk.cyan("\\-")}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- Child agent status ---
|
|
325
|
+
export function printChildSpawn(port, pid, label) {
|
|
326
|
+
const name = label || `agent@${port}`;
|
|
327
|
+
addLine(` ${chalk.cyan("*")} ${chalk.cyan(`Spawned ${name} on port ${port}`)} ${chalk.dim(`(pid ${pid})`)}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function printChildEvent(port, text, label) {
|
|
331
|
+
const name = label || `Agent@${port}`;
|
|
332
|
+
addLine(` ${chalk.cyan("*")} ${chalk.dim(`${name} ${text}`)}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Background process output (dim, box drawing, 4-space indent) ---
|
|
336
|
+
// A background process's start is the ledger's "sh start" line (its outcome
|
|
337
|
+
// column names the process); only its end and the all-finished summary are
|
|
338
|
+
// printed here, in the ledger's style (owner, 2026-10-01: the start used to be
|
|
339
|
+
// printed twice, once here and once by the ledger).
|
|
340
|
+
export function printProcessEnd(procId, cmd, exitCode, elapsed, status) {
|
|
341
|
+
// Stopped with /kill: say so, not the exit code taskkill left behind.
|
|
342
|
+
const how = status === "killed" ? "killed" : exitCode === 0 ? "done" : `exit ${exitCode ?? "?"}`;
|
|
343
|
+
// After a kill, what is still running and what the next Esc would stop, on
|
|
344
|
+
// the same line (owner, 2026-10-01: Esc used to print a line of its own and
|
|
345
|
+
// then this one, two lines for one press).
|
|
346
|
+
const left = status === "killed"
|
|
347
|
+
? (_store?.getState().processes || []).filter((p) => p.status === "running").length
|
|
348
|
+
: 0;
|
|
349
|
+
const hint = left ? chalk.dim(` · ${left} still running, Esc stops the newest`) : "";
|
|
350
|
+
addLine(processLedgerLine({ verb: how, cmd, procId, elapsed }) + hint);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function printProcessSummary(total, done, failed, killed) {
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (done) parts.push(`${done} done`);
|
|
356
|
+
if (failed) parts.push(`${failed} failed`);
|
|
357
|
+
if (killed) parts.push(`${killed} killed`);
|
|
358
|
+
addLine(chalk.dim(` ── all ${total} background processes finished: ${parts.join(", ")} ──`));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- Stats line ---
|
|
362
|
+
export function printStats(iters, costStr, sessionCost) {
|
|
363
|
+
addLine(` ${chalk.dim(`--- ${iters}${costStr} | session $${sessionCost}`)}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- System / info messages ---
|
|
367
|
+
export function printSystem(text) {
|
|
368
|
+
addLine(` ${chalk.gray(text)}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function printWarning(text) {
|
|
372
|
+
addLine(` ${chalk.yellow(text)}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function printError(text) {
|
|
376
|
+
addLine(` ${chalk.red(text)}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- Confirmation prompt ---
|
|
380
|
+
/**
|
|
381
|
+
* One history line for an answered approval. The question itself is shown
|
|
382
|
+
* only in the live zone while it waits (components/LiveZone.js).
|
|
383
|
+
*/
|
|
384
|
+
export function printConfirmResult(type, toolName = "", argsText = "") {
|
|
385
|
+
const what = `${toolName}${argsText ? " " + argsText : ""}`.slice(0, 100);
|
|
386
|
+
if (type === "allow") addLine(chalk.green(" + ") + chalk.dim(what));
|
|
387
|
+
else if (type === "always") addLine(chalk.green(" + ") + chalk.dim(what) + chalk.green(" (always)"));
|
|
388
|
+
else addLine(chalk.red(" x denied ") + chalk.dim(what));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Table rendering ---
|
|
392
|
+
export function printTable(columns, rows, title, footer) {
|
|
393
|
+
_store.getState().addTable({ columns, rows, title, footer });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- Blank line ---
|
|
397
|
+
export function printBlank() {
|
|
398
|
+
addLine("");
|
|
399
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Pasted text and images stand in the input as short tokens (owner,
|
|
2
|
+
// 2026-10-01): a big paste shows as "[Pasted text #1 · 42 lines]", a picture
|
|
3
|
+
// as "[Image #1]". The full text is put back when the message is sent; the
|
|
4
|
+
// history line keeps the token.
|
|
5
|
+
|
|
6
|
+
// A single input event this big is a paste worth hiding behind a token.
|
|
7
|
+
// Typing and dictation arrive a character or a word at a time, and a paste of
|
|
8
|
+
// two or three lines reads fine as it is.
|
|
9
|
+
const PASTE_MIN_CHARS = 300;
|
|
10
|
+
const PASTE_MIN_LINES = 4;
|
|
11
|
+
// Further chunks of the same paste arrive within this window.
|
|
12
|
+
export const PASTE_MERGE_MS = 150;
|
|
13
|
+
|
|
14
|
+
const TOKEN_RE = /\[Pasted text #(\d+)[^\]]*\]/g;
|
|
15
|
+
const IMAGE_TOKEN_RE = /\[Image #(\d+)\]/g;
|
|
16
|
+
|
|
17
|
+
/** Does one inserted chunk look like a paste? */
|
|
18
|
+
export function isPasteChunk(added) {
|
|
19
|
+
const s = String(added || "");
|
|
20
|
+
if (s.length < 2) return false;
|
|
21
|
+
return s.split("\n").length >= PASTE_MIN_LINES || s.length >= PASTE_MIN_CHARS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The token that stands for pasted text in the input. */
|
|
25
|
+
export function pasteToken(id, text) {
|
|
26
|
+
const s = String(text || "");
|
|
27
|
+
const lines = s.split("\n").length;
|
|
28
|
+
const size = lines > 1 ? `${lines} lines` : `${s.length} chars`;
|
|
29
|
+
return `[Pasted text #${id} · ${size}]`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// How much of a paste is shown, top lines first (owner, 2026-10-01: "show two
|
|
33
|
+
// or three lines vertically, then +15 more").
|
|
34
|
+
export const PREVIEW_LINES = 3;
|
|
35
|
+
|
|
36
|
+
/** The first lines of a paste and how many more there are. */
|
|
37
|
+
export function previewLines(text, max = PREVIEW_LINES) {
|
|
38
|
+
const lines = String(text || "").replace(/\n+$/, "").split("\n");
|
|
39
|
+
return { shown: lines.slice(0, max), more: Math.max(0, lines.length - max) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The input as the history should show it: each paste token opened into its
|
|
44
|
+
* first lines and a "+N more lines" note, so what was pasted can be seen
|
|
45
|
+
* without the whole of it.
|
|
46
|
+
*/
|
|
47
|
+
export function previewPastes(input, pastes, max = PREVIEW_LINES) {
|
|
48
|
+
return String(input || "").replace(TOKEN_RE, (whole, id) => {
|
|
49
|
+
const text = pastes.get(Number(id));
|
|
50
|
+
if (text == null) return whole;
|
|
51
|
+
const { shown, more } = previewLines(text, max);
|
|
52
|
+
const tail = more ? [`… +${more} more line${more === 1 ? "" : "s"}`] : [];
|
|
53
|
+
return ["", ...shown.map((l) => ` ${l}`), ...tail.map((l) => ` ${l}`)].join("\n");
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rows for the block above the input while composing: for each paste token in
|
|
59
|
+
* the input, a header and its first lines. At most `maxPastes` blocks, so the
|
|
60
|
+
* live zone stays small.
|
|
61
|
+
*/
|
|
62
|
+
export function composeRows(input, pastes, { max = PREVIEW_LINES, maxPastes = 2 } = {}) {
|
|
63
|
+
const rows = [];
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const m of String(input || "").matchAll(TOKEN_RE)) {
|
|
66
|
+
const id = Number(m[1]);
|
|
67
|
+
const text = pastes.get(id);
|
|
68
|
+
if (text == null) continue;
|
|
69
|
+
if (count === maxPastes) { rows.push("… more pasted text in the input"); break; }
|
|
70
|
+
count++;
|
|
71
|
+
const { shown, more } = previewLines(text, max);
|
|
72
|
+
rows.push(`┌ pasted #${id}`);
|
|
73
|
+
for (const l of shown) rows.push(`│ ${l}`);
|
|
74
|
+
rows.push(more ? `└ +${more} more line${more === 1 ? "" : "s"}` : "└");
|
|
75
|
+
}
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The input with every paste token replaced by the text it stands for. */
|
|
80
|
+
export function expandPastes(input, pastes) {
|
|
81
|
+
return String(input || "").replace(TOKEN_RE, (whole, id) => {
|
|
82
|
+
const text = pastes.get(Number(id));
|
|
83
|
+
return text == null ? whole : text;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The image numbers referenced in the input, in order, each once. */
|
|
88
|
+
export function imageIds(input) {
|
|
89
|
+
const ids = [];
|
|
90
|
+
for (const m of String(input || "").matchAll(IMAGE_TOKEN_RE)) {
|
|
91
|
+
const id = Number(m[1]);
|
|
92
|
+
if (!ids.includes(id)) ids.push(id);
|
|
93
|
+
}
|
|
94
|
+
return ids;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Text arriving at the cursor (typed, pasted, or Ctrl+V), given the paste
|
|
99
|
+
* state. A big chunk becomes a token; the next chunk of the same paste, if it
|
|
100
|
+
* arrives right after that token within PASTE_MERGE_MS, grows it.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} a
|
|
103
|
+
* @param {string} a.value the input
|
|
104
|
+
* @param {number} a.cursor where the text goes
|
|
105
|
+
* @param {string} a.chunk the text (normalized)
|
|
106
|
+
* @param {Map} a.pastes id -> text, mutated when a paste is made or grown
|
|
107
|
+
* @param {object} a.state { nextId, last: {id, token, at} | null }, mutated
|
|
108
|
+
* @param {number} [a.now]
|
|
109
|
+
* @returns {{value: string, cursor: number}}
|
|
110
|
+
*/
|
|
111
|
+
export function applyInsert({ value, cursor, chunk, pastes, state, now = Date.now() }) {
|
|
112
|
+
const before = value.slice(0, cursor);
|
|
113
|
+
const after = value.slice(cursor);
|
|
114
|
+
const last = state.last;
|
|
115
|
+
if (last && now - last.at < PASTE_MERGE_MS && chunk.length > 1 && before.endsWith(last.token)) {
|
|
116
|
+
const text = pastes.get(last.id) + chunk;
|
|
117
|
+
pastes.set(last.id, text);
|
|
118
|
+
const token = pasteToken(last.id, text);
|
|
119
|
+
state.last = { id: last.id, token, at: now };
|
|
120
|
+
const head = before.slice(0, before.length - last.token.length) + token;
|
|
121
|
+
return { value: head + after, cursor: head.length };
|
|
122
|
+
}
|
|
123
|
+
if (isPasteChunk(chunk)) {
|
|
124
|
+
const id = state.nextId++;
|
|
125
|
+
pastes.set(id, chunk);
|
|
126
|
+
const token = pasteToken(id, chunk);
|
|
127
|
+
state.last = { id, token, at: now };
|
|
128
|
+
return { value: before + token + after, cursor: before.length + token.length };
|
|
129
|
+
}
|
|
130
|
+
return { value: before + chunk + after, cursor: cursor + chunk.length };
|
|
131
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Make an open prompt noticeable from outside the window.
|
|
2
|
+
//
|
|
3
|
+
// The owner's report on 2026-09-29: an approval prompt for edit_file sat
|
|
4
|
+
// untouched for about seven minutes. The console looked idle — "thinking #28
|
|
5
|
+
// 458s" — and the conclusion was that Flint had hung. It had not; it was
|
|
6
|
+
// waiting, and the only visible sign of waiting was a line of text in a window
|
|
7
|
+
// nobody was looking at.
|
|
8
|
+
//
|
|
9
|
+
// Two channels, because they fail differently:
|
|
10
|
+
//
|
|
11
|
+
// BEL (\x07) Windows Terminal (and most terminals) flash the taskbar
|
|
12
|
+
// entry. A bell in a window you cannot see is the one signal
|
|
13
|
+
// that reaches you at all. Guarded on isTTY: in a pipe or a
|
|
14
|
+
// log file, \x07 is a stray control character, not an alert,
|
|
15
|
+
// and it is worse than nothing — it corrupts captured output.
|
|
16
|
+
// window title "[!] Flint needs you", restored when answered. Survives
|
|
17
|
+
// scrollback, is visible from Alt+Tab, and works in hosts
|
|
18
|
+
// that swallow the bell.
|
|
19
|
+
//
|
|
20
|
+
// Both are started and stopped as a pair, and stop() is idempotent, because the
|
|
21
|
+
// call sites are a promise boundary: the prompt can be answered, timed out, or
|
|
22
|
+
// abandoned by a reset, and a title left saying "needs you" after the question
|
|
23
|
+
// is gone is worse than never having set it — it trains the operator to ignore
|
|
24
|
+
// the one word that should have been trusted.
|
|
25
|
+
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
|
|
28
|
+
const BELL = "\x07";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The title shown while a prompt is open.
|
|
32
|
+
*
|
|
33
|
+
* "Flint needs you" is the spec's wording and the title bar has room for it.
|
|
34
|
+
* "waiting for approval" is added because a title has to work as a scan target:
|
|
35
|
+
* read out of context from an Alt+Tab list, "needs you" is a sentence that
|
|
36
|
+
* could mean anything, and "waiting for approval" cannot be mistaken for
|
|
37
|
+
* anything else. Both are in it so neither reading is lost.
|
|
38
|
+
*/
|
|
39
|
+
export const ATTENTION_TITLE = "[!] Flint needs you — waiting for approval";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Note that a prompt is open.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {boolean} [opts.isTTY] — defaults to this process's stdout
|
|
46
|
+
* @param {string} [opts.previousTitle] — the title to restore; read from the
|
|
47
|
+
* terminal if not given
|
|
48
|
+
* @param {(title: string) => void} [opts.setTitle] — injectable so this is
|
|
49
|
+
* testable without a terminal; defaults to writing the OSC 0 sequence
|
|
50
|
+
* @param {(char: string) => void} [opts.write] — injectable, same reason
|
|
51
|
+
* @returns {{active: boolean, rings: boolean, previousTitle: string|null}}
|
|
52
|
+
*/
|
|
53
|
+
export function promptAttentionStart({
|
|
54
|
+
isTTY = Boolean(process.stdout?.isTTY),
|
|
55
|
+
previousTitle,
|
|
56
|
+
setTitle = defaultSetTitle,
|
|
57
|
+
write = defaultWrite,
|
|
58
|
+
} = {}) {
|
|
59
|
+
const previous = previousTitle ?? readTitle();
|
|
60
|
+
|
|
61
|
+
// Ring first. The title change alone is easy to absorb without noticing, and
|
|
62
|
+
// this call is the one that reaches a window that is not in front.
|
|
63
|
+
let rings = false;
|
|
64
|
+
if (isTTY) {
|
|
65
|
+
try {
|
|
66
|
+
write(BELL);
|
|
67
|
+
rings = true;
|
|
68
|
+
} catch {
|
|
69
|
+
// A terminal that cannot take the bell is not a reason to skip the title.
|
|
70
|
+
rings = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
setTitle(ATTENTION_TITLE);
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
return { active: true, rings, previousTitle: previous, setTitle };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The prompt is answered (or gone). Put the window back the way it was.
|
|
83
|
+
*
|
|
84
|
+
* Idempotent, and safe to call with nothing — the call sites race the timeout
|
|
85
|
+
* against the answer, and "stop an attention signal that is not running" has to
|
|
86
|
+
* be a no-op rather than a second restore.
|
|
87
|
+
*
|
|
88
|
+
* The `setTitle` used to restore comes from the state returned by start, not
|
|
89
|
+
* from a second argument. An earlier version took it as an argument, which meant
|
|
90
|
+
* a caller that had injected one (every test, and any host embedding the TUI)
|
|
91
|
+
* had to pass it twice, and the ones that did not got the real terminal instead
|
|
92
|
+
* of the one they were watching. The title was then set on the injected sink
|
|
93
|
+
* and restored on the real terminal: the test saw the title never change, and
|
|
94
|
+
* the operator's actual window was left reading "[!] Flint needs you" after the
|
|
95
|
+
* question was answered. A leaked "[!]" is worse than no signal at all.
|
|
96
|
+
*
|
|
97
|
+
* @returns {boolean} whether anything was actually restored
|
|
98
|
+
*/
|
|
99
|
+
export function promptAttentionStop(state, { setTitle } = {}) {
|
|
100
|
+
if (!state || !state.active) return false;
|
|
101
|
+
state.active = false;
|
|
102
|
+
const restore = state.setTitle || setTitle || defaultSetTitle;
|
|
103
|
+
try {
|
|
104
|
+
restore(state.previousTitle ?? "");
|
|
105
|
+
} catch {}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultWrite(str) {
|
|
110
|
+
process.stdout.write(str);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read the current title from the terminal, when the host tracks one. */
|
|
114
|
+
function readTitle() {
|
|
115
|
+
return process.stdout?.title ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set the terminal/window title.
|
|
120
|
+
*
|
|
121
|
+
* OSC 0 sets both the icon name and the window title, which is the one Windows
|
|
122
|
+
* Terminal puts in the taskbar entry — the place the signal has to land.
|
|
123
|
+
* process.stdout.title is tried first because on Windows it goes through the
|
|
124
|
+
* console API, and a terminal that supports it does the taskbar flash for us.
|
|
125
|
+
*/
|
|
126
|
+
export function defaultSetTitle(title) {
|
|
127
|
+
if (process.stdout && "title" in process.stdout) {
|
|
128
|
+
process.stdout.title = title;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (process.stdout?.isTTY) {
|
|
132
|
+
process.stdout.write(`\x1b]0;${title}\x07`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Ink render options for the console, shared by index.js and the screen test
|
|
2
|
+
// (tests/unit/components/console-screen.test.js), so the test renders exactly
|
|
3
|
+
// what the operator gets.
|
|
4
|
+
//
|
|
5
|
+
// incrementalRendering stays off. It was turned on to stop the live zone
|
|
6
|
+
// flickering and, on 2026-10-01, every spinner frame was left behind in the
|
|
7
|
+
// scrollback ("/ thinking..." stacked line after line). Rendering the real App
|
|
8
|
+
// into a headless xterm reproduced it with the option on and showed a clean
|
|
9
|
+
// screen with it off.
|
|
10
|
+
export const RENDER_OPTIONS = Object.freeze({
|
|
11
|
+
exitOnCtrlC: false,
|
|
12
|
+
incrementalRendering: false,
|
|
13
|
+
});
|