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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The first-run question, as a choice rather than a wall of text.
|
|
2
|
+
//
|
|
3
|
+
// The first version of this prompt was one line of three bracketed options
|
|
4
|
+
// and an instruction to pick a letter:
|
|
5
|
+
//
|
|
6
|
+
// How careful should Flint be?
|
|
7
|
+
// [s]afe (asks before every change to files and every command) [n]ormal ...
|
|
8
|
+
// — pick s, n or p
|
|
9
|
+
//
|
|
10
|
+
// On 2026-09-30 11:05 the owner read that as a wall of text and did not know
|
|
11
|
+
// what was being asked. What was missing was not more words — it was the shape
|
|
12
|
+
// of a decision: a heading that says what is being decided and that this is
|
|
13
|
+
// asked once, then the options where the eye goes, one per line, with the
|
|
14
|
+
// current answer visibly current.
|
|
15
|
+
//
|
|
16
|
+
// The options come from levelOptions(), the same list the old prompt and
|
|
17
|
+
// parseLevelAnswer() already used, so the menu cannot offer a level that the
|
|
18
|
+
// parser would not record. That was the class of bug the first version hit: the
|
|
19
|
+
// prompt promised `n` was accepted and nothing accepted it.
|
|
20
|
+
import React from "react";
|
|
21
|
+
import { Box, Text, useInput } from "ink";
|
|
22
|
+
import { levelOptions, parseLevelAnswer, DEFAULT_ONBOARDING_LEVEL } from "../security/policies.js";
|
|
23
|
+
|
|
24
|
+
const { createElement: h } = React;
|
|
25
|
+
|
|
26
|
+
const DEFAULT_INDEX = Math.max(
|
|
27
|
+
0,
|
|
28
|
+
levelOptions().findIndex((o) => o.level === DEFAULT_ONBOARDING_LEVEL),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One keystroke, as a decision.
|
|
33
|
+
*
|
|
34
|
+
* Returned rather than handled inline so the mapping from key to answer is a
|
|
35
|
+
* value with no React in it — the arrows and the letters are two answers to
|
|
36
|
+
* the same question, and a function that returns "move down" or "safe" makes
|
|
37
|
+
* "Enter" the only place that has to know what either means.
|
|
38
|
+
*
|
|
39
|
+
* @returns {"up"|"down"|"select"|"cancel"|null}
|
|
40
|
+
*/
|
|
41
|
+
export function keyAction(ch, key, index, count) {
|
|
42
|
+
if (key.upArrow) return "up";
|
|
43
|
+
if (key.downArrow) return "down";
|
|
44
|
+
if (key.escape) return "cancel";
|
|
45
|
+
if (key.return) return "select";
|
|
46
|
+
if (key.tab) return "down";
|
|
47
|
+
if (!ch) return null;
|
|
48
|
+
const level = parseLevelAnswer(ch);
|
|
49
|
+
if (!level) return null;
|
|
50
|
+
// A letter jumps to and picks its option in one keystroke, which is what
|
|
51
|
+
// typing `n` did before. Kept because the prompt still advertises it.
|
|
52
|
+
return level;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} props
|
|
57
|
+
* @param {(level: string) => void} props.onSelect — the chosen level, already
|
|
58
|
+
* validated against LEVELS by parseLevelAnswer.
|
|
59
|
+
* @param {() => void} [props.onCancel] — Esc. No level is chosen, so the
|
|
60
|
+
* question comes back next start and the documented default applies: a
|
|
61
|
+
* cancelled question is not an answer.
|
|
62
|
+
* @param {number} [props.initialIndex]
|
|
63
|
+
*/
|
|
64
|
+
export function CarefulMenu({ onSelect, onCancel, initialIndex = DEFAULT_INDEX }) {
|
|
65
|
+
const [index, setIndex] = React.useState(initialIndex);
|
|
66
|
+
const [done, setDone] = React.useState(false);
|
|
67
|
+
const options = levelOptions();
|
|
68
|
+
|
|
69
|
+
const choose = (i) => {
|
|
70
|
+
if (done) return;
|
|
71
|
+
// Latched before the callback: a key held down, or a key and an Enter in
|
|
72
|
+
// the same tick, must not record two postures.
|
|
73
|
+
setDone(true);
|
|
74
|
+
onSelect(options[i].level);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
useInput((ch, key) => {
|
|
78
|
+
if (done) return;
|
|
79
|
+
const action = keyAction(ch, key, index, options.length);
|
|
80
|
+
if (action === "up") {
|
|
81
|
+
setIndex((i) => Math.max(0, i - 1));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (action === "down") {
|
|
85
|
+
setIndex((i) => Math.min(options.length - 1, i + 1));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (action === "cancel") {
|
|
89
|
+
setDone(true);
|
|
90
|
+
onCancel?.();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (action === "select") {
|
|
94
|
+
choose(index);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// A level name or its letter came through.
|
|
98
|
+
const idx = options.findIndex((o) => o.level === action);
|
|
99
|
+
// findIndex returns -1 when the action is not one of the levels. It used
|
|
100
|
+
// to fall through to choose(-1) and crash the menu with "Cannot read
|
|
101
|
+
// properties of undefined (reading 'level')" — the one keystroke this
|
|
102
|
+
// component exists for, answered by pressing the wrong key, is exactly the
|
|
103
|
+
// keystroke that must never throw. A junk key is not an answer and is not
|
|
104
|
+
// a move either, so it is ignored, not rounded to an option.
|
|
105
|
+
if (idx === -1) return;
|
|
106
|
+
choose(idx);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return h(
|
|
110
|
+
Box,
|
|
111
|
+
{ flexDirection: "column" },
|
|
112
|
+
h(Text, { bold: true, color: "cyan" }, " How careful should Flint be?"),
|
|
113
|
+
h(
|
|
114
|
+
Text,
|
|
115
|
+
{ dimColor: true },
|
|
116
|
+
" This sets when Flint asks before it changes something. It is asked once,",
|
|
117
|
+
),
|
|
118
|
+
h(Text, { dimColor: true }, " and you can change it later with /careful."),
|
|
119
|
+
h(Text, { dimColor: true }, ""),
|
|
120
|
+
...options.map((option, i) => {
|
|
121
|
+
const selected = i === index;
|
|
122
|
+
const isDefault = option.level === DEFAULT_ONBOARDING_LEVEL;
|
|
123
|
+
// The default is marked as text, not only as colour: colour is the part
|
|
124
|
+
// an operator with a monochrome terminal or a screen reader never sees.
|
|
125
|
+
const label = `${selected ? "❯" : " "} ${option.level}`;
|
|
126
|
+
const tag = isDefault ? " (default)" : "";
|
|
127
|
+
const line = `${label}${tag}`;
|
|
128
|
+
return h(
|
|
129
|
+
Text,
|
|
130
|
+
{
|
|
131
|
+
key: option.level,
|
|
132
|
+
bold: selected,
|
|
133
|
+
color: selected ? "green" : isDefault ? "cyan" : "white",
|
|
134
|
+
},
|
|
135
|
+
` ${line} ${option.description}`,
|
|
136
|
+
);
|
|
137
|
+
}),
|
|
138
|
+
h(Text, { dimColor: true }, ""),
|
|
139
|
+
h(
|
|
140
|
+
Text,
|
|
141
|
+
{ dimColor: true },
|
|
142
|
+
` Up/Down to choose, Enter to confirm, or type ${options.map((o) => o.key).join(", ")} — Esc to skip.`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// HistoryWriter: prints finished lines into the terminal's own scrollback.
|
|
2
|
+
//
|
|
3
|
+
// docs/console-spec.md. History used to go through Ink <Static> fed with the
|
|
4
|
+
// store's `lines` array, which is capped at maxDisplayLines (1000). <Static>
|
|
5
|
+
// prints items past the count it has already printed, so once the array hit
|
|
6
|
+
// the cap its length stopped growing and nothing new was printed at all; and
|
|
7
|
+
// every remount (tab switch, back from PgUp) printed all 1000 lines again.
|
|
8
|
+
//
|
|
9
|
+
// Here each line is written once, by id, through Ink's stdout writer, which
|
|
10
|
+
// clears the live zone, writes above it and redraws it. Ids only grow, so the
|
|
11
|
+
// cap on `lines` no longer matters to what is shown, and nothing is ever
|
|
12
|
+
// reprinted. Scrolling and selection belong to the terminal.
|
|
13
|
+
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { useStdout, renderToString } from "ink";
|
|
16
|
+
import { Table } from "./Table.js";
|
|
17
|
+
|
|
18
|
+
const { createElement: h, useEffect, useRef } = React;
|
|
19
|
+
|
|
20
|
+
/** One history item as the text the terminal should show. */
|
|
21
|
+
export function historyItemText(item, columns = process.stdout.columns || 80) {
|
|
22
|
+
if (item?.type === "table") {
|
|
23
|
+
return renderToString(
|
|
24
|
+
h(Table, { columns: item.columns, rows: item.rows, title: item.title, footer: item.footer }),
|
|
25
|
+
{ columns },
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return String(item?.text ?? "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Items not yet written, given the last written id. A /clear resets ids to 1,
|
|
33
|
+
* which the caller signals by passing lastId 0.
|
|
34
|
+
*/
|
|
35
|
+
export function unwrittenItems(lines, lastId) {
|
|
36
|
+
return (lines || []).filter((l) => l && l.id > lastId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function HistoryWriter({ store }) {
|
|
40
|
+
const { write } = useStdout();
|
|
41
|
+
const lastId = useRef(0);
|
|
42
|
+
const lastClear = useRef(store.getState().clearCounter || 0);
|
|
43
|
+
const scheduled = useRef(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let alive = true;
|
|
47
|
+
// New lines are collected the moment they are added, not at write time:
|
|
48
|
+
// `lines` keeps only the last maxDisplayLines, so a burst bigger than that
|
|
49
|
+
// within one tick evicted its first lines before they were ever written
|
|
50
|
+
// (acceptance check 1, 2026-10-02: lines 0 to 999 of 2,000 never shown).
|
|
51
|
+
const buffer = [];
|
|
52
|
+
const collect = () => {
|
|
53
|
+
const s = store.getState();
|
|
54
|
+
const clear = s.clearCounter || 0;
|
|
55
|
+
if (clear !== lastClear.current) {
|
|
56
|
+
lastClear.current = clear;
|
|
57
|
+
lastId.current = 0;
|
|
58
|
+
buffer.length = 0;
|
|
59
|
+
}
|
|
60
|
+
const fresh = unwrittenItems(s.lines, lastId.current);
|
|
61
|
+
if (!fresh.length) return false;
|
|
62
|
+
lastId.current = fresh[fresh.length - 1].id;
|
|
63
|
+
buffer.push(...fresh);
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
const flush = () => {
|
|
67
|
+
scheduled.current = false;
|
|
68
|
+
if (!alive || !buffer.length) return;
|
|
69
|
+
const items = buffer.splice(0, buffer.length);
|
|
70
|
+
write(items.map((item) => historyItemText(item)).join("\n") + "\n");
|
|
71
|
+
};
|
|
72
|
+
// One write per tick, not one per addLine: a streamed answer adds many
|
|
73
|
+
// lines in a burst, and each write clears and redraws the live zone.
|
|
74
|
+
const schedule = () => {
|
|
75
|
+
if (!collect() || scheduled.current) return;
|
|
76
|
+
scheduled.current = true;
|
|
77
|
+
queueMicrotask(flush);
|
|
78
|
+
};
|
|
79
|
+
collect();
|
|
80
|
+
flush();
|
|
81
|
+
const unsubscribe = store.subscribe(schedule);
|
|
82
|
+
return () => { alive = false; unsubscribe(); };
|
|
83
|
+
}, [store, write]);
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// LineInput: the console's input line with a real cursor (owner, 2026-10-01).
|
|
2
|
+
//
|
|
3
|
+
// Replaces ink-text-input, whose cursor was either drawn as an inverse block
|
|
4
|
+
// that showed up in odd places, or, with showCursor off, did not move at all.
|
|
5
|
+
// This one draws no caret of its own: the App puts the terminal's thin bar
|
|
6
|
+
// cursor where `cursor` is (ui/line-edit.js cursorCell). Paste/image tokens
|
|
7
|
+
// move and delete as one character.
|
|
8
|
+
//
|
|
9
|
+
// Keys: Left/Right, Ctrl+Left/Right (also Alt+B/F) by word, Home/End and
|
|
10
|
+
// Ctrl+A/E, Backspace, Delete, Ctrl+W (word back), Ctrl+U (clear), Enter.
|
|
11
|
+
// Up/Down, Esc, Tab and the App's own Ctrl shortcuts are left to the App.
|
|
12
|
+
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { Text, useInput } from "ink";
|
|
15
|
+
import {
|
|
16
|
+
moveLeft, moveRight, wordLeft, wordRight, insertAt, backspace,
|
|
17
|
+
deleteForward, deleteWordBack, wrapRows,
|
|
18
|
+
} from "../ui/line-edit.js";
|
|
19
|
+
|
|
20
|
+
const { createElement: h } = React;
|
|
21
|
+
|
|
22
|
+
const same = (a, b) => a.value === b.value && a.cursor === b.cursor;
|
|
23
|
+
|
|
24
|
+
export function LineInput({ value, cursor, onChange, onSubmit, onInsert, mask, focus = true, width = 80, rowStart = 0, maxRows = Infinity }) {
|
|
25
|
+
// Keys can arrive faster than renders (a paste in chunks, dictation). The
|
|
26
|
+
// line is kept here and updated on every key, so a second key never sees the
|
|
27
|
+
// state from before the first. `pending` holds what was sent up and not yet
|
|
28
|
+
// seen back as props: props that match one of those are this component's
|
|
29
|
+
// own echo; anything else is the App replacing the line (history recall,
|
|
30
|
+
// clearing on submit), which wins.
|
|
31
|
+
const stateRef = React.useRef({ value, cursor });
|
|
32
|
+
const pendingRef = React.useRef([]);
|
|
33
|
+
const fromProps = { value, cursor };
|
|
34
|
+
const echo = pendingRef.current.findIndex((p) => same(p, fromProps));
|
|
35
|
+
if (echo >= 0) pendingRef.current = pendingRef.current.slice(echo + 1);
|
|
36
|
+
else if (!same(stateRef.current, fromProps)) { stateRef.current = fromProps; pendingRef.current = []; }
|
|
37
|
+
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
const state = stateRef.current;
|
|
40
|
+
({ value, cursor } = state);
|
|
41
|
+
if (key.return) { onSubmit?.(value); return; }
|
|
42
|
+
if (key.upArrow || key.downArrow || key.escape || key.tab || key.pageUp || key.pageDown) return;
|
|
43
|
+
|
|
44
|
+
let next = null;
|
|
45
|
+
if (key.leftArrow) next = key.ctrl || key.meta ? wordLeft(state) : moveLeft(state);
|
|
46
|
+
else if (key.rightArrow) next = key.ctrl || key.meta ? wordRight(state) : moveRight(state);
|
|
47
|
+
else if (key.home || (key.ctrl && input === "a")) next = { value, cursor: 0 };
|
|
48
|
+
else if (key.end || (key.ctrl && input === "e")) next = { value, cursor: value.length };
|
|
49
|
+
else if (key.meta && input === "b") next = wordLeft(state);
|
|
50
|
+
else if (key.meta && input === "f") next = wordRight(state);
|
|
51
|
+
else if (key.backspace) next = backspace(state);
|
|
52
|
+
else if (key.delete) next = deleteForward(state);
|
|
53
|
+
else if (key.ctrl && input === "w") next = deleteWordBack(state);
|
|
54
|
+
else if (key.ctrl && input === "u") next = { value: "", cursor: 0 };
|
|
55
|
+
else if (key.ctrl || key.meta) return; // the App's shortcuts (Ctrl+C, Ctrl+V, ...)
|
|
56
|
+
else if (input) next = onInsert ? onInsert(input, state) : insertAt(state, input);
|
|
57
|
+
|
|
58
|
+
if (next && !same(next, state)) {
|
|
59
|
+
stateRef.current = next;
|
|
60
|
+
pendingRef.current.push(next);
|
|
61
|
+
onChange?.(next);
|
|
62
|
+
}
|
|
63
|
+
}, { isActive: focus });
|
|
64
|
+
|
|
65
|
+
const drawn = mask ? mask.repeat(value.length) : value;
|
|
66
|
+
// A long line shows only the rows around the cursor (the App picks
|
|
67
|
+
// rowStart), so the input cannot push the live output to the window height.
|
|
68
|
+
return h(Text, null, wrapRows(drawn, width).slice(rowStart, rowStart + maxRows).join("\n"));
|
|
69
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The live zone: the only part of the console that is redrawn.
|
|
2
|
+
//
|
|
3
|
+
// docs/console-spec.md. Ink clears the whole terminal and reprints history on
|
|
4
|
+
// every render once its live output is as tall as the window
|
|
5
|
+
// (node_modules/ink/build/ink.js:322). Everything here is one row per item and
|
|
6
|
+
// bounded, so the zone stays a few rows high at any terminal size.
|
|
7
|
+
//
|
|
8
|
+
// activity what Flint is doing, or the line of the answer being written
|
|
9
|
+
// confirm a pending approval or pairing (only when there is one)
|
|
10
|
+
// dock running background processes, at most DOCK_ROWS
|
|
11
|
+
// --------
|
|
12
|
+
// input (App)
|
|
13
|
+
// status one row
|
|
14
|
+
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { Box, Text } from "ink";
|
|
17
|
+
import { formatStatusLevel } from "../ui/status-level.js";
|
|
18
|
+
import { toolCategory, ledgerWidth, fitMiddle } from "../ui/tool-ledger.js";
|
|
19
|
+
import wrapAnsi from "wrap-ansi";
|
|
20
|
+
|
|
21
|
+
const { createElement: h } = React;
|
|
22
|
+
|
|
23
|
+
export const DOCK_ROWS = 3;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Seconds as m:ss. A bare 468 is not obviously nine minutes, and 468 was what
|
|
27
|
+
* the status line showed for a hung call on 2026-09-29.
|
|
28
|
+
*/
|
|
29
|
+
export function formatElapsed(ms) {
|
|
30
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
31
|
+
const m = Math.floor(total / 60);
|
|
32
|
+
const s = total % 60;
|
|
33
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
// The footer's activity block (owner, 2026-10-01): fixed width, so the footer
|
|
38
|
+
// never shifts, and nothing in it but a spinner, a verb, the clock and the
|
|
39
|
+
// token count. What is being run goes in the window above (liveToolText).
|
|
40
|
+
//
|
|
41
|
+
// · ready idle
|
|
42
|
+
// ⠚ thinking 00:03 waiting for the model
|
|
43
|
+
// ⠓ writing 00:07 ↓ 312 tok the model is answering
|
|
44
|
+
// ⠋ running 01:23 a tool is running
|
|
45
|
+
// A 2x2 square of dots in one character, a gap running round it clockwise
|
|
46
|
+
// (the owner's pick, 2026-10-01). Idle: a dot and "ready". The full square
|
|
47
|
+
// and "waiting" it had first read as a spinner stuck on a busy agent, twice
|
|
48
|
+
// (owner, 2026-10-02): idle must not look like any frame of the spinner.
|
|
49
|
+
const SQUARE_FRAMES = ["⠚", "⠓", "⠋", "⠙"];
|
|
50
|
+
export const SPINNER_FRAME_MS = 150;
|
|
51
|
+
export const IDLE_MARK = "·";
|
|
52
|
+
// Kept for the App's redraw interval.
|
|
53
|
+
export const DOTS_FRAME_MS = SPINNER_FRAME_MS;
|
|
54
|
+
|
|
55
|
+
/** The rotating square of dots at a moment in time. */
|
|
56
|
+
export function spinnerFrame(now = Date.now()) {
|
|
57
|
+
return SQUARE_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SQUARE_FRAMES.length];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// eslint-disable-next-line no-control-regex
|
|
61
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
62
|
+
|
|
63
|
+
const VERB_WIDTH = 9;
|
|
64
|
+
const TOKENS_WIDTH = 10;
|
|
65
|
+
export const ACTIVITY_BLOCK_WIDTH = 2 + VERB_WIDTH + 1 + 5 + 1 + TOKENS_WIDTH;
|
|
66
|
+
|
|
67
|
+
/** One word for what Flint is doing. */
|
|
68
|
+
export function activityVerb(state) {
|
|
69
|
+
if (!state.agentStatus || state.agentStatus === "idle") return "ready";
|
|
70
|
+
switch (state.activity?.kind) {
|
|
71
|
+
case "start": return "starting";
|
|
72
|
+
case "call": return "thinking";
|
|
73
|
+
case "answering": return "writing";
|
|
74
|
+
case "tool": return "running";
|
|
75
|
+
case "stall": return "stalled";
|
|
76
|
+
case "wait": return "retrying";
|
|
77
|
+
default: return state.agentStatus === "calling-tool" ? "running" : "thinking";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function clock(ms) {
|
|
82
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
83
|
+
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The fixed-width block that leads the footer, idle or busy. */
|
|
87
|
+
export function activityText(state, now = Date.now()) {
|
|
88
|
+
const busy = state.agentStatus && state.agentStatus !== "idle";
|
|
89
|
+
const mark = busy ? spinnerFrame(now) : IDLE_MARK;
|
|
90
|
+
const verb = activityVerb(state).padEnd(VERB_WIDTH);
|
|
91
|
+
if (!busy) return `${mark} ${verb}`.padEnd(ACTIVITY_BLOCK_WIDTH);
|
|
92
|
+
const since = state.activity ? state.activityStartedAt : state.startedAt;
|
|
93
|
+
const time = since ? clock(now - since) : " ";
|
|
94
|
+
const tokens = state.activityTokens || 0;
|
|
95
|
+
const tok = tokens > 0 ? `↓ ${tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : tokens} tok` : "";
|
|
96
|
+
return `${mark} ${verb} ${time} ${tok.padStart(TOKENS_WIDTH)}`.padEnd(ACTIVITY_BLOCK_WIDTH);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The running tool, in the window above the input, in the ledger's style:
|
|
101
|
+
* category, verb, argument, and the latest output line. "" when no tool runs.
|
|
102
|
+
* When the tool finishes, its ledger line replaces this in the history.
|
|
103
|
+
*/
|
|
104
|
+
export function liveToolText(state, columns = process.stdout.columns || 80) {
|
|
105
|
+
const a = state.activity;
|
|
106
|
+
if (!a || a.kind !== "tool" || !state.agentStatus || state.agentStatus === "idle") return "";
|
|
107
|
+
const [cat, verb] = toolCategory(a.tool || state.currentTool || "");
|
|
108
|
+
const width = ledgerWidth(columns);
|
|
109
|
+
const head = ` ${cat.padEnd(6)}${verb.padEnd(8)}`;
|
|
110
|
+
const detail = state.activityDetail ? String(state.activityDetail).replace(ANSI, "") : "";
|
|
111
|
+
// The command wins the room; the output line takes what is left and the
|
|
112
|
+
// row's own truncation cuts it at the edge.
|
|
113
|
+
const argRoom = Math.max(10, width - head.length - 12);
|
|
114
|
+
const arg = fitMiddle(a.arg || "", Math.min(argRoom, [...(a.arg || "")].length || 1)).trimEnd();
|
|
115
|
+
return detail ? `${head}${arg} | ${detail}` : `${head}${arg}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The confirm row, or "" when nothing waits for the operator. */
|
|
119
|
+
// An approval shows the whole command, never a cut one: a dangerous part may
|
|
120
|
+
// sit exactly where it would be cut (owner, 2026-10-01). Up to this many
|
|
121
|
+
// wrapped rows go in the live zone; a longer command is printed in full into
|
|
122
|
+
// the history when the question is asked (bootstrap.js) and pointed to here.
|
|
123
|
+
export const APPROVAL_MAX_ROWS = 6;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether an approval's wrapped command is shown in the live zone (true) or
|
|
127
|
+
* printed in full into the history with a pointer here (false). One rule for
|
|
128
|
+
* both places, so a command is never neither. While a question waits the input
|
|
129
|
+
* is one row, so this always fits the live zone's budget.
|
|
130
|
+
*/
|
|
131
|
+
export function approvalFitsLive(lineCount, rows = process.stdout.rows || 24) {
|
|
132
|
+
return lineCount <= Math.min(APPROVAL_MAX_ROWS, Math.max(1, rows - 12));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The arguments of an approval, wrapped to the window, indented, nothing cut. */
|
|
136
|
+
export function approvalArgLines(argsText, columns = process.stdout.columns || 80) {
|
|
137
|
+
const text = String(argsText || "");
|
|
138
|
+
if (!text) return [];
|
|
139
|
+
const width = Math.max(20, columns - 1 - 4);
|
|
140
|
+
return wrapAnsi(text, width, { hard: true, trim: false }).split("\n").map((l) => ` ${l}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The approval or pairing rows of the live zone; [] when nothing waits. */
|
|
144
|
+
export function confirmRows(state, now = Date.now(), columns = process.stdout.columns || 80) {
|
|
145
|
+
if (state.pendingPairing) {
|
|
146
|
+
const p = state.pendingPairing;
|
|
147
|
+
const left = Math.max(0, Math.ceil((p.expiresAt - now) / 1000));
|
|
148
|
+
const who = p.agentName ? `${p.agentName}@${p.fromAddress}` : p.fromAddress;
|
|
149
|
+
return [` [!] Pairing from ${who}: PIN ${p.pin} (${left}s). Type "C" to cancel.`];
|
|
150
|
+
}
|
|
151
|
+
if (state.pendingConfirmation) {
|
|
152
|
+
const head = ` ? ${state.pendingConfirmation} [y] yes [n] no [a] always`;
|
|
153
|
+
const lines = approvalArgLines(state.pendingConfirmationArgs, columns);
|
|
154
|
+
if (approvalFitsLive(lines.length)) return [head, ...lines];
|
|
155
|
+
return [head, ` full command printed above ↑ (${lines.length} lines)`];
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The first approval row, for callers that want one line. */
|
|
161
|
+
export function confirmText(state, now = Date.now(), columns) {
|
|
162
|
+
return confirmRows(state, now, columns)[0] || "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Running background processes, newest first, at most `limit` rows. */
|
|
166
|
+
export function dockRows(processes, limit = DOCK_ROWS) {
|
|
167
|
+
const running = (processes || []).filter((p) => p.status === "running").sort((a, b) => b.id - a.id);
|
|
168
|
+
const rows = running.slice(0, limit).map((p) => {
|
|
169
|
+
// The "(x2)" repeat counter is for /logs; at the bottom of the screen it
|
|
170
|
+
// only flickers (owner, 2026-10-01).
|
|
171
|
+
const last = String((p.output || [])[p.output.length - 1] || "").replace(/\s*\(x\d+\)$/, "");
|
|
172
|
+
return ` [bg ${p.id}] ${p.command || p.cmd}${last ? ` | ${String(last).replace(/\s+/g, " ")}` : ""}`;
|
|
173
|
+
});
|
|
174
|
+
if (running.length > limit) {
|
|
175
|
+
rows[rows.length - 1] = ` ... ${running.length - limit + 1} more running, /ps to list`;
|
|
176
|
+
}
|
|
177
|
+
return rows;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 950, 22k, 1M, 1.5M: a token count as short as it can be read. */
|
|
181
|
+
export function compactCount(n) {
|
|
182
|
+
if (n < 1000) return String(n);
|
|
183
|
+
if (n < 1e6) return `${Math.round(n / 1000)}k`;
|
|
184
|
+
return `${(n / 1e6).toFixed(1).replace(/.0$/, "")}M`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Unread messages shown above the input, newest last, at most this many.
|
|
188
|
+
export const QUEUE_ROWS = 3;
|
|
189
|
+
|
|
190
|
+
/** Rows for the messages typed while the agent works and not yet read. */
|
|
191
|
+
export function queueRows(queued, limit = QUEUE_ROWS) {
|
|
192
|
+
const list = queued || [];
|
|
193
|
+
if (!list.length) return [];
|
|
194
|
+
const first = (t) => String(t || "").split("\n").map((l) => l.trim()).find(Boolean) || "";
|
|
195
|
+
const shown = list.slice(-limit).map((q) => ` queued ${first(q.display)}`);
|
|
196
|
+
if (list.length > limit) shown.unshift(` queued … ${list.length - limit} earlier, not read yet`);
|
|
197
|
+
// Esc takes the newest one back into the input (App.js).
|
|
198
|
+
shown[shown.length - 1] += " · Esc to edit";
|
|
199
|
+
return shown;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The single status row. */
|
|
203
|
+
export function statusText(state) {
|
|
204
|
+
const parts = [];
|
|
205
|
+
if (state.model) parts.push(state.model);
|
|
206
|
+
if (state.sessionCost != null) parts.push(`${state.sessionCostEstimated ? "~" : ""}$${state.sessionCost.toFixed(4)}`);
|
|
207
|
+
if (state.contextTokens && state.contextLimit) {
|
|
208
|
+
// Size, not a percentage: "ctx 1%" of a 1M window hid 10k of context
|
|
209
|
+
// (owner, 2026-10-01).
|
|
210
|
+
// "~" while it is an estimate (a session just loaded, no reply yet).
|
|
211
|
+
parts.push(`ctx ${state.contextEstimated ? "~" : ""}${compactCount(state.contextTokens)}/${compactCount(state.contextLimit)}`);
|
|
212
|
+
}
|
|
213
|
+
// What changes during work first; the care level and /help last, where the
|
|
214
|
+
// footer's right edge may cut them on a narrow window.
|
|
215
|
+
const bg = (state.processes || []).filter((p) => p.status === "running").length;
|
|
216
|
+
if (bg) parts.push(`${bg} bg`);
|
|
217
|
+
if (state.queued > 0) parts.push(`${state.queued} queued`);
|
|
218
|
+
if (state.autoMode) {
|
|
219
|
+
const a = state.autoMode;
|
|
220
|
+
parts.push(`AUTO ${a.tasksDone}/${a.tasksTotal}`);
|
|
221
|
+
}
|
|
222
|
+
parts.push(formatStatusLevel());
|
|
223
|
+
if (state.spendLevel) parts.push(`spend: ${state.spendLevel}`);
|
|
224
|
+
if (state.freeQuota) parts.push(`free ${state.freeQuota.used}/${state.freeQuota.limit}`);
|
|
225
|
+
parts.push("/help");
|
|
226
|
+
return ` ${parts.join(" | ")}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The live zone's rows, fitted to a budget (owner, 2026-10-01: every part was
|
|
231
|
+
* bounded, but all of them together could still reach the window's height,
|
|
232
|
+
* and then Ink clears and reprints the whole session on every render).
|
|
233
|
+
*
|
|
234
|
+
* Parts in order of importance: an approval or pairing, the running tool,
|
|
235
|
+
* unread messages, background processes, the paste preview. Each gets what
|
|
236
|
+
* is left of the budget; one that does not fit whole is folded into a
|
|
237
|
+
* one-line count (or dropped, for the paste preview). The approval is never
|
|
238
|
+
* folded: confirmRows already points to the history when its command does
|
|
239
|
+
* not fit.
|
|
240
|
+
*
|
|
241
|
+
* @returns {Array<{key: string, text: string, props: object}>}
|
|
242
|
+
*/
|
|
243
|
+
export function layoutLiveZone(state, { pasteRows = [], budget = Infinity, now = Date.now(), columns } = {}) {
|
|
244
|
+
const out = [];
|
|
245
|
+
let left = Math.max(0, budget);
|
|
246
|
+
const take = (key, rows, props, fold) => {
|
|
247
|
+
if (!rows.length || left <= 0) return;
|
|
248
|
+
if (rows.length <= left) {
|
|
249
|
+
rows.forEach((text, i) => out.push({ key: `${key}-${i}`, text, props: typeof props === "function" ? props(i) : props }));
|
|
250
|
+
left -= rows.length;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (fold) {
|
|
254
|
+
out.push({ key: `${key}-fold`, text: fold(rows), props: typeof props === "function" ? props(0) : props });
|
|
255
|
+
left -= 1;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
take("confirm", confirmRows(state, now, columns), (i) => ({ color: i === 0 ? "yellowBright" : "white" }));
|
|
259
|
+
const tool = liveToolText(state);
|
|
260
|
+
take("tool", tool ? [tool] : [], { dimColor: true });
|
|
261
|
+
const queued = state.queuedInputs || [];
|
|
262
|
+
take("queue", queueRows(queued), { color: "gray" },
|
|
263
|
+
() => ` queued ${queued.length} message${queued.length === 1 ? "" : "s"}, not read yet · Esc to edit`);
|
|
264
|
+
const running = (state.processes || []).filter((p) => p.status === "running").length;
|
|
265
|
+
take("dock", dockRows(state.processes), { dimColor: true },
|
|
266
|
+
() => ` ${running} background process${running === 1 ? "" : "es"} running, /ps to list`);
|
|
267
|
+
take("paste", pasteRows.map((r) => ` ${r}`), { dimColor: true });
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Rows the zone above the separator takes, for the layout cap and its test. */
|
|
272
|
+
export function liveZoneRows(state, now = Date.now(), opts = {}) {
|
|
273
|
+
return layoutLiveZone(state, { ...opts, now }).length;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const row = (text, props = {}) => h(Box, null, h(Text, { wrap: "truncate-end", ...props }, text));
|
|
277
|
+
|
|
278
|
+
export function LiveZoneTop({ state, pasteRows = [], budget = Infinity }) {
|
|
279
|
+
const rows = layoutLiveZone(state, { pasteRows, budget });
|
|
280
|
+
return h(Box, { flexDirection: "column" },
|
|
281
|
+
...rows.map((r) => h(Box, { key: r.key }, h(Text, { wrap: "truncate-end", ...r.props }, r.text))),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The footer: the fixed-width activity block, then the session facts. The
|
|
287
|
+
* block is the same width idle or busy, so nothing after it moves.
|
|
288
|
+
*/
|
|
289
|
+
export function StatusLine({ state }) {
|
|
290
|
+
return h(Box, null, h(Text, { wrap: "truncate-end" },
|
|
291
|
+
h(Text, { color: state.agentStatus && state.agentStatus !== "idle" ? "white" : "gray" }, ` ${activityText(state)}`),
|
|
292
|
+
h(Text, { dimColor: true }, `│${statusText(state)}`),
|
|
293
|
+
));
|
|
294
|
+
}
|