atom-agent 1.1.0 → 1.3.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/CHANGELOG.md +106 -0
- package/README.md +18 -8
- package/atom.example.json +11 -0
- package/dist/App.js +1637 -255
- package/dist/adapters.js +112 -21
- package/dist/agent/gates.js +14 -1
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +716 -132
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +25 -3
- package/dist/compact.js +169 -17
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +46 -8
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +159 -41
- package/dist/session.js +23 -5
- package/dist/sessions.js +543 -0
- package/dist/system.js +89 -13
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +247 -17
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +45 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +3 -1
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +370 -87
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/ui/diff-view.js
CHANGED
|
@@ -29,7 +29,12 @@ function syntaxColor(kind) {
|
|
|
29
29
|
// an offset cursor paints every char exactly once (text integrity is
|
|
30
30
|
// pinned by tests — highlighting must never alter content). Exported:
|
|
31
31
|
// the side-by-side view (ui/side-by-side) reuses it per pane cell.
|
|
32
|
-
|
|
32
|
+
//
|
|
33
|
+
// Memoized: props are (lineText, runs, base, lang) — `runs` keeps identity
|
|
34
|
+
// from the parent's useMemo'd diff, and highlightLine is itself line-cached,
|
|
35
|
+
// so unrelated parent renders (ticks, keystrokes, appends elsewhere) skip
|
|
36
|
+
// both the walk and the tokenize lookup.
|
|
37
|
+
export const LineBody = React.memo(function LineBody({ lineText, runs, base, lang, }) {
|
|
33
38
|
const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
|
|
34
39
|
const hlBg = base === "add" ? "green" : "red";
|
|
35
40
|
const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
|
|
@@ -80,7 +85,7 @@ export function LineBody({ lineText, runs, base, lang, }) {
|
|
|
80
85
|
nodes.push(_jsx(Text, { children: parts }, k));
|
|
81
86
|
});
|
|
82
87
|
return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
|
|
83
|
-
}
|
|
88
|
+
});
|
|
84
89
|
function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
|
|
85
90
|
const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
|
|
86
91
|
if (diff.skipped) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Live-tail host: the subscription boundary between App and the streaming UI.
|
|
3
|
+
//
|
|
4
|
+
// App renders this host with LOW-frequency props only (busy/held/empty flags,
|
|
5
|
+
// elapsed seconds, tool hint). The HIGH-frequency streaming text (draft +
|
|
6
|
+
// thinking, up to ~15 paints/sec via DRAFT_THROTTLE_MS) flows through the
|
|
7
|
+
// StreamStore instead: this host subscribes via useSyncExternalStore, so a
|
|
8
|
+
// token paint re-renders this host + LiveTail alone — App's body,
|
|
9
|
+
// reconciliation of every other leaf, and their prop assembly never run.
|
|
10
|
+
//
|
|
11
|
+
// LiveTail itself is untouched (same props API, same paint), so all existing
|
|
12
|
+
// LiveTail tests keep passing; only the delivery path changed.
|
|
13
|
+
import React, { useSyncExternalStore } from "react";
|
|
14
|
+
import { LiveTail } from "./live-tail.js";
|
|
15
|
+
export const LiveTailHost = React.memo(function LiveTailHost({ store, isEmpty, sessionHint, emptySessionTitle, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking, }) {
|
|
16
|
+
const snap = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
17
|
+
return (_jsx(LiveTail, { isEmpty: isEmpty, sessionHint: sessionHint, emptySessionTitle: emptySessionTitle, draft: snap.draft, thinking: snap.thinking, busy: busy, held: held, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }));
|
|
18
|
+
});
|
package/dist/ui/live-tail.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Live-tail leaf: the dynamic zone between the committed <Static>
|
|
3
|
+
// transcript and the modals — empty-state hints, the streaming answer
|
|
4
|
+
// draft, the transient thinking block, and the tool-call hint. Re-renders
|
|
5
|
+
// every tick by design (unlike TranscriptView/InputBox); all paint comes
|
|
6
|
+
// from ui/theme tokens. The streaming-markdown chunk owns this file next.
|
|
7
|
+
import React from "react";
|
|
2
8
|
import { Box, Text } from "ink";
|
|
3
9
|
import { activityText } from "./activity.js";
|
|
4
10
|
import { MarkdownStream } from "./markdown.js";
|
|
5
11
|
import { theme } from "./theme.js";
|
|
6
|
-
export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
|
|
12
|
+
export const LiveTail = React.memo(function LiveTail({ isEmpty, sessionHint, emptySessionTitle, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
|
|
7
13
|
// Held view (user scrolled up mid-turn): the growing draft/thinking blocks
|
|
8
14
|
// are replaced by one static line so the frame stops gaining terminal
|
|
9
15
|
// lines — the terminal stops yanking and scrollback stays readable. The
|
|
@@ -11,5 +17,5 @@ export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, to
|
|
|
11
17
|
// status (tool hint, thinking tick) keeps updating in place: same line,
|
|
12
18
|
// no growth, no yank.
|
|
13
19
|
const freezeLive = held === true && busy;
|
|
14
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
|
|
15
|
-
}
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, isEmpty && emptySessionTitle && emptySessionTitle.trim() ? (_jsxs(Text, { dimColor: true, children: ["Session: ", emptySessionTitle.trim()] })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
|
|
21
|
+
});
|
package/dist/ui/markdown.js
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Zero-dependency markdown renderer for assistant transcript turns.
|
|
3
|
+
//
|
|
4
|
+
// Terminal-native hierarchy, no boxes: headings are bold, lists use one
|
|
5
|
+
// consistent bullet (nesting as 2-space indents, task lists as ballot
|
|
6
|
+
// boxes), code blocks are indentation + a dim language label (```/~~~
|
|
7
|
+
// fences, unclosed runs to end of input), tables are aligned columns with
|
|
8
|
+
// one dim separator row, links read as `text (url)`. Soft line breaks join
|
|
9
|
+
// (true markdown); blank lines separate paragraphs. Raw fences/bold-markers
|
|
10
|
+
// never leak: when the model emits plain text, it paints back byte-identical.
|
|
11
|
+
// Long code lines are never pre-wrapped or truncated — Ink wraps them and
|
|
12
|
+
// the source line stays intact for copy/paste; long table cells truncate
|
|
13
|
+
// with `…` so one cell never blows out the grid.
|
|
14
|
+
//
|
|
15
|
+
// Performance: parsed blocks are cached per exact input (bounded FIFO), so
|
|
16
|
+
// re-renders and long sessions never re-parse. Parsing is linear in input
|
|
17
|
+
// size; rendering stays one <Text> per run (no per-character nodes).
|
|
18
|
+
import React from "react";
|
|
2
19
|
import { Box, Text } from "ink";
|
|
3
20
|
import { theme } from "./theme.js";
|
|
4
21
|
// Split `s` on inline-code spans first (code content is never formatted),
|
|
@@ -481,10 +498,17 @@ export function closeStreamingMarkers(s) {
|
|
|
481
498
|
// block cursor riding the final run. Converges to MarkdownText byte-for-
|
|
482
499
|
// byte once the stream completes (cursor aside), so commit never visually
|
|
483
500
|
// jumps.
|
|
484
|
-
|
|
501
|
+
//
|
|
502
|
+
// Memoized on `text` — the ONLY parse input (TABLE_MAX_COL is a fixed
|
|
503
|
+
// const, wrapping is Ink's job, the cursor glyph is a module const). A 1s
|
|
504
|
+
// busy tick re-renders the parent with identical text and must NOT reparse:
|
|
505
|
+
// same text bails here, changed text re-parses (one linear pass).
|
|
506
|
+
export const streamParseProbe = { count: 0 };
|
|
507
|
+
export const MarkdownStream = React.memo(function MarkdownStream({ text }) {
|
|
508
|
+
streamParseProbe.count += 1;
|
|
485
509
|
const blocks = parseMarkdown(closeStreamingMarkers(text) + theme.symbol.cursorBar);
|
|
486
510
|
return (_jsx(Box, { flexDirection: "column", children: blocks.map((b, k) => (_jsx(BlockView, { block: b, gap: k > 0 }, k))) }));
|
|
487
|
-
}
|
|
511
|
+
});
|
|
488
512
|
// Assistant body: full markdown when the text parses into structure,
|
|
489
513
|
// byte-identical plain text otherwise (a single paragraph paints its runs;
|
|
490
514
|
// with no formatting syntax those runs are the input verbatim).
|
package/dist/ui/palette.js
CHANGED
|
@@ -9,7 +9,7 @@ import { Box, Text } from "ink";
|
|
|
9
9
|
import { PickerMoreAbove, PickerMoreBelow, pickerWindow } from "./pickers.js";
|
|
10
10
|
import { theme } from "./theme.js";
|
|
11
11
|
export const PALETTE_WINDOW = 12;
|
|
12
|
-
export const PALETTE_CATEGORY_ORDER = ["Model", "Session", "Tools", "Skills", "Flow", "Help"];
|
|
12
|
+
export const PALETTE_CATEGORY_ORDER = ["Model", "Session", "Tools", "Skills", "Flow", "Extensions", "Help"];
|
|
13
13
|
const PALETTE_CATEGORIES = {
|
|
14
14
|
"/model": "Model",
|
|
15
15
|
"/provider": "Model",
|
|
@@ -18,6 +18,8 @@ const PALETTE_CATEGORIES = {
|
|
|
18
18
|
"/clear": "Session",
|
|
19
19
|
"/new": "Session",
|
|
20
20
|
"/resume": "Session",
|
|
21
|
+
"/rename": "Session",
|
|
22
|
+
"/session": "Session",
|
|
21
23
|
"/rewind": "Session",
|
|
22
24
|
"/context": "Session",
|
|
23
25
|
"/telemetry": "Session",
|
package/dist/ui/side-by-side.js
CHANGED
|
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// lines align; changed regions pop via the existing word-background +
|
|
10
10
|
// add/del line-number treatment; syntax colors reused per cell.
|
|
11
11
|
// - hunks only (configurable context in the engine) — never whole files.
|
|
12
|
-
// - width-aware: panes split the measured terminal (useStdout, same
|
|
13
|
-
// pattern as
|
|
12
|
+
// - width-aware: panes split the measured terminal (local useStdout, same
|
|
13
|
+
// pattern as StatusBarHost); long lines truncate per pane with …
|
|
14
14
|
// (code-point safe); below NARROW_COLUMNS the view degrades to the
|
|
15
15
|
// stacked unified DiffView instead of destroying the layout.
|
|
16
16
|
// - computed once per mount (useMemo, keyed on inputs + pane width) and
|
package/dist/ui/status-bar.js
CHANGED
|
@@ -12,6 +12,47 @@ import React from "react";
|
|
|
12
12
|
import { Box, Text } from "ink";
|
|
13
13
|
import { formatTokenSegment } from "../context-windows.js";
|
|
14
14
|
import { theme } from "./theme.js";
|
|
15
|
+
// Default objective budget for the goal segment: compact enough to share the
|
|
16
|
+
// line with the pinned model/token/mode segments at 100 columns.
|
|
17
|
+
export const GOAL_STATUS_OBJECTIVE_CHARS = 32;
|
|
18
|
+
// Truncate an objective to n chars max (`…` tail keeps the start, which
|
|
19
|
+
// carries the verb). n < 4 yields "" (the caller drops the segment instead).
|
|
20
|
+
export function truncateGoalObjective(objective, max = GOAL_STATUS_OBJECTIVE_CHARS) {
|
|
21
|
+
const text = typeof objective === "string" ? objective : "";
|
|
22
|
+
if (text.length <= max)
|
|
23
|
+
return text;
|
|
24
|
+
if (max < 4)
|
|
25
|
+
return "";
|
|
26
|
+
return `${text.slice(0, max - 1)}…`;
|
|
27
|
+
}
|
|
28
|
+
// Full goal segment at the default budget, or null when no goal is live.
|
|
29
|
+
// Paused reads distinct from active (`[paused]` vs `[active]`).
|
|
30
|
+
export function formatGoalSegment(goal, max = GOAL_STATUS_OBJECTIVE_CHARS) {
|
|
31
|
+
if (!goal || typeof goal.objective !== "string" || goal.objective.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
const state = goal.active === true ? "active" : "paused";
|
|
34
|
+
return `goal: ${truncateGoalObjective(goal.objective, max)} [${state}]`;
|
|
35
|
+
}
|
|
36
|
+
// Fit the goal segment into `room` chars (the width left after every other
|
|
37
|
+
// segment): full text when it fits, a shorter truncation when it almost
|
|
38
|
+
// fits, null (drop the segment) when even a stub would displace the line.
|
|
39
|
+
// Never throws; never returns "".
|
|
40
|
+
export function fitGoalSegment(goal, room) {
|
|
41
|
+
if (!goal || typeof goal.objective !== "string" || goal.objective.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
if (typeof room !== "number" || !Number.isFinite(room) || room <= 0)
|
|
44
|
+
return null;
|
|
45
|
+
const state = goal.active === true ? "active" : "paused";
|
|
46
|
+
const full = `goal: ${goal.objective} [${state}]`;
|
|
47
|
+
if (full.length <= room)
|
|
48
|
+
return full;
|
|
49
|
+
// Room for at least 4 objective chars plus the fixed framing, else drop.
|
|
50
|
+
const overhead = `goal: [${state}]`.length + 1;
|
|
51
|
+
const allow = Math.floor(room - overhead);
|
|
52
|
+
if (allow < 4)
|
|
53
|
+
return null;
|
|
54
|
+
return `goal: ${truncateGoalObjective(goal.objective, allow)} [${state}]`;
|
|
55
|
+
}
|
|
15
56
|
// ~/… collapse + tail-cut: informative, never a full scroll of nesting.
|
|
16
57
|
// Further shrinking for tight widths goes through shrinkTo below (the bar
|
|
17
58
|
// measures first and only renders what fits).
|
|
@@ -34,9 +75,13 @@ export function shrinkTo(s, n) {
|
|
|
34
75
|
// render (same-props parent churn — token paints, keystrokes, unrelated
|
|
35
76
|
// ticks — must skip it; only changed props repaint).
|
|
36
77
|
export const statusBarRenderProbe = { count: 0 };
|
|
37
|
-
export const StatusBar = React.memo(function StatusBar({ provider, model, usageTotals, contextLoad, reasoningDisplay, mode, trustAll, busy, activity, phaseLabel, elapsedSecs, stalled, approvalPending, cwd, branch, columns = 100, }) {
|
|
78
|
+
export const StatusBar = React.memo(function StatusBar({ provider, model, usageTotals, contextLoad, reasoningDisplay, mode, trustAll, busy, activity, phaseLabel, elapsedSecs, stalled, approvalPending, cwd, branch, columns = 100, extensionStatus, goal, }) {
|
|
38
79
|
statusBarRenderProbe.count += 1;
|
|
39
80
|
const bar = theme.symbol.bar;
|
|
81
|
+
// Extension guest slot (ticket 10): pre-budgeted text renders only when
|
|
82
|
+
// the full line still fits — the fixed-width contract above. The `+ 3`
|
|
83
|
+
// is the ` ${bar} ` separator the segment carries with it.
|
|
84
|
+
const hasExt = typeof extensionStatus === "string" && extensionStatus.length > 0;
|
|
40
85
|
if (!busy) {
|
|
41
86
|
const token = formatTokenSegment(usageTotals, model, contextLoad);
|
|
42
87
|
const trust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
@@ -46,7 +91,8 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
|
|
|
46
91
|
// order: branch → cwd tail → the whole segment.
|
|
47
92
|
const tail = `reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${trust}`;
|
|
48
93
|
const baseLen = `${provider}/${model} ${bar} ${token} ${bar} ${bar} ${tail}`.length;
|
|
49
|
-
const
|
|
94
|
+
const showExt = hasExt && baseLen + extensionStatus.length + 3 + 2 <= columns;
|
|
95
|
+
const avail = columns - baseLen - (showExt ? extensionStatus.length + 3 : 0);
|
|
50
96
|
let loc = null;
|
|
51
97
|
if (cwd) {
|
|
52
98
|
const branchPart = branch ? ` : ${branch}` : "";
|
|
@@ -61,15 +107,44 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
|
|
|
61
107
|
loc = shrunk ? shrunk : null;
|
|
62
108
|
}
|
|
63
109
|
}
|
|
64
|
-
|
|
110
|
+
// Goal segment (ticket 09): lowest-priority builtin — it takes only the
|
|
111
|
+
// width left after every other segment and drops whole rather than push
|
|
112
|
+
// the line past `columns`. Hidden entirely with no goal.
|
|
113
|
+
const lineSoFar = baseLen + (showExt ? extensionStatus.length + 3 : 0) + (loc ? loc.length + 3 : 0);
|
|
114
|
+
const goalSeg = fitGoalSegment(goal ?? null, columns - lineSoFar - 2);
|
|
115
|
+
return (_jsx(Box, { marginTop: theme.spacing.statusMarginTop, children: _jsxs(Text, { dimColor: true, children: [provider, "/", model, " ", bar, " ", token, showExt ? (_jsxs(_Fragment, { children: [" ", bar, " ", extensionStatus] })) : null, loc ? (_jsxs(_Fragment, { children: [" ", bar, " ", loc] })) : null, " ", bar, " reasoning: ", reasoningDisplay, " ", bar, " mode: ", mode, trust ? "+trust" : null, goalSeg ? (_jsxs(_Fragment, { children: [" ", bar, " ", goalSeg] })) : null] }) }));
|
|
65
116
|
}
|
|
66
117
|
// Busy layout prioritizes activity + clock + interrupt hint; the mode
|
|
67
118
|
// stays pinned (it used to vanish while working), and the reasoning
|
|
68
119
|
// effort stays visible (it used to vanish while working). The activity text
|
|
69
120
|
// shrinks to fit so `esc stops` never wraps away.
|
|
70
121
|
const busyTrust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
71
|
-
const
|
|
122
|
+
const busyToken = formatTokenSegment(usageTotals, model, contextLoad);
|
|
123
|
+
// Goal segment (ticket 09): a guest in the fixed part — capped at 48
|
|
124
|
+
// chars and rendered only when the FULL activity text still fits beside
|
|
125
|
+
// it. Otherwise the goal drops whole and every existing segment renders
|
|
126
|
+
// exactly as with no goal (the goal never displaces, same precedent as
|
|
127
|
+
// the extension guest above). The clock, token, mode, and esc-hint
|
|
128
|
+
// segments never move for it either way.
|
|
129
|
+
const busyGoalSeg = fitGoalSegment(goal ?? null, 48);
|
|
130
|
+
const busyGoalCandidate = busyGoalSeg ? ` ${bar} ${busyGoalSeg}` : "";
|
|
131
|
+
const activityFull = activity ?? phaseLabel;
|
|
132
|
+
const busyCore = ` ${bar} ${elapsedSecs}s ${bar} ${busyToken} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust}`;
|
|
133
|
+
const busyTail = ` ${bar} esc stops`;
|
|
134
|
+
const busyExtCandidate = hasExt && `${busyCore}${busyGoalCandidate}${busyTail}`.length + extensionStatus.length + 3 + 2 <= columns
|
|
135
|
+
? ` ${bar} ${extensionStatus}`
|
|
136
|
+
: "";
|
|
137
|
+
const withGoalFixed = `${busyCore}${busyExtCandidate}${busyGoalCandidate}${busyTail}`;
|
|
138
|
+
// Room check against the unfitted activity text: when it no longer fits
|
|
139
|
+
// whole with the goal aboard, the goal yields (drop whole, recompute).
|
|
140
|
+
const busyGoalPart = busyGoalCandidate !== "" &&
|
|
141
|
+
withGoalFixed.length + activityFull.length + 2 <= columns
|
|
142
|
+
? busyGoalCandidate
|
|
143
|
+
: "";
|
|
144
|
+
const busyNoExt = `${busyCore}${busyGoalPart}${busyTail}`;
|
|
145
|
+
const showBusyExt = hasExt && busyNoExt.length + extensionStatus.length + 3 + 2 <= columns;
|
|
146
|
+
const busyFixed = ` ${bar} ${elapsedSecs}s${showBusyExt ? ` ${bar} ${extensionStatus}` : ""} ${bar} ${busyToken} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust}${busyGoalPart} ${bar} esc stops`;
|
|
72
147
|
const busyAvail = columns - busyFixed.length - 2;
|
|
73
|
-
const activityText = shrinkTo(
|
|
148
|
+
const activityText = shrinkTo(activityFull, Math.max(0, busyAvail));
|
|
74
149
|
return (_jsx(Box, { marginTop: theme.spacing.statusMarginTop, children: _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.color.activity, children: [theme.symbol.workTool, " ", activityText] }), busyFixed, stalled && !approvalPending ? ` ${bar} waiting${theme.symbol.ellipsis}` : null, approvalPending ? (_jsxs(Text, { color: theme.color.warning, children: [" ", bar, " waiting approval"] })) : null] }) }));
|
|
75
150
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Status-bar host: keeps the terminal-width subscription out of App.
|
|
3
|
+
//
|
|
4
|
+
// App used to call useStdout() in its own body to measure columns for the
|
|
5
|
+
// bar's fit-or-drop logic, coupling the whole App render to stdout changes.
|
|
6
|
+
// This memoized host owns that read instead: resizes re-render the bar
|
|
7
|
+
// alone. StatusBar itself is untouched (same props API); `columns` becomes
|
|
8
|
+
// an optional override (tests keep passing explicit widths, production
|
|
9
|
+
// measures). The 100 fallback matches StatusBar's own default.
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { useStdout } from "ink";
|
|
12
|
+
import { StatusBar } from "./status-bar.js";
|
|
13
|
+
export const StatusBarHost = React.memo(function StatusBarHost(props) {
|
|
14
|
+
let measured;
|
|
15
|
+
try {
|
|
16
|
+
measured = useStdout()?.stdout?.columns;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
measured = undefined;
|
|
20
|
+
}
|
|
21
|
+
return _jsx(StatusBar, { ...props, columns: props.columns ?? measured ?? 100 });
|
|
22
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const EMPTY = { draft: null, thinking: null };
|
|
2
|
+
export function createStreamStore() {
|
|
3
|
+
let snapshot = EMPTY;
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
function emit() {
|
|
6
|
+
for (const cb of [...listeners]) {
|
|
7
|
+
try {
|
|
8
|
+
cb();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// A throwing listener must not break the remaining subscribers.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function assign(next) {
|
|
16
|
+
if (next.draft === snapshot.draft && next.thinking === snapshot.thinking)
|
|
17
|
+
return;
|
|
18
|
+
snapshot = next;
|
|
19
|
+
emit();
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
getSnapshot: () => snapshot,
|
|
23
|
+
subscribe: (cb) => {
|
|
24
|
+
listeners.add(cb);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(cb);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
setDraft: (text) => {
|
|
30
|
+
if (text === snapshot.draft)
|
|
31
|
+
return;
|
|
32
|
+
assign({ draft: text, thinking: snapshot.thinking });
|
|
33
|
+
},
|
|
34
|
+
setThinking: (text) => {
|
|
35
|
+
if (text === snapshot.thinking)
|
|
36
|
+
return;
|
|
37
|
+
assign({ draft: snapshot.draft, thinking: text });
|
|
38
|
+
},
|
|
39
|
+
getDraft: () => snapshot.draft,
|
|
40
|
+
getThinking: () => snapshot.thinking,
|
|
41
|
+
clear: () => {
|
|
42
|
+
if (snapshot !== EMPTY) {
|
|
43
|
+
snapshot = EMPTY;
|
|
44
|
+
emit();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { truncateHead } from "../tools/shared.js";
|
|
3
4
|
import { theme } from "./theme.js";
|
|
4
5
|
export const MAX_TOOL_RECORDS = 50;
|
|
5
6
|
export const STORE_CHARS = 32768;
|
|
@@ -8,7 +9,12 @@ export const LIST_WINDOW = 15;
|
|
|
8
9
|
export function createToolRecord(id, label, result, isError, ms) {
|
|
9
10
|
const text = result ?? "";
|
|
10
11
|
const truncated = text.length > STORE_CHARS;
|
|
11
|
-
|
|
12
|
+
// Line-aware store cap (issue 04): the stored head never ends mid-line.
|
|
13
|
+
// Single-giant-line inputs keep the hard cut (documented tail edge case),
|
|
14
|
+
// so over-cap single-line results still store exactly STORE_CHARS.
|
|
15
|
+
const stored = truncated
|
|
16
|
+
? truncateHead(text, STORE_CHARS, "\n[truncated: stored output exceeded 32KB]").head
|
|
17
|
+
: text;
|
|
12
18
|
return {
|
|
13
19
|
id,
|
|
14
20
|
label,
|
package/dist/ui/transcript.js
CHANGED
|
@@ -1,39 +1,37 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// Transcript leaves: the committed <Static> scrollback, its item renderer,
|
|
3
3
|
// and the startup banner. Prop-driven + memoized (see comments) so App state
|
|
4
4
|
// churn never repaints them. Turn is the display-transcript entry shape.
|
|
5
5
|
// All paint comes from ui/theme tokens — no literal colors or glyphs here.
|
|
6
6
|
import React from "react";
|
|
7
|
-
import { Box, Text } from "ink";
|
|
7
|
+
import { Box, Static, Text } from "ink";
|
|
8
8
|
import { SideBySideDiffView, TRANSCRIPT_DIFF_MAX_LINES } from "./side-by-side.js";
|
|
9
9
|
import { ErrorCard, classifyToolError } from "./errors.js";
|
|
10
10
|
import { MarkdownText, ToolLine } from "./markdown.js";
|
|
11
11
|
import { theme } from "./theme.js";
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// Commit frontier model: the committed transcript prints to terminal
|
|
13
|
+
// scrollback ONCE via <Static> and is never rewritten (this is what keeps
|
|
14
|
+
// a full-page transcript from flashing on every keystroke — Ink takes a
|
|
15
|
+
// clearTerminal + full-reprint path for fullscreen dynamic frames).
|
|
15
16
|
//
|
|
16
|
-
// Model: E =
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// Model: E = committed end index (null = follow: commit everything). Any
|
|
18
|
+
// E < len is manual mode — new turns accumulate below the frontier and a
|
|
19
|
+
// `↓ N new` indicator offers the jump back. Printed output can never
|
|
20
|
+
// retract, so E below the committed count holds back future commits only;
|
|
21
|
+
// deep history lives in terminal scrollback. Banner shows on fresh mounts.
|
|
22
|
+
// List replacement (/clear, /resume, /new, /rewind) bumps clearGen, which
|
|
23
|
+
// resets the Static buffer via the identity below.
|
|
22
24
|
export const SCROLLBACK_WINDOW = 300;
|
|
23
25
|
export const SCROLL_PAGE_ITEMS = 10;
|
|
24
|
-
export function resolveViewport(len, end, win = SCROLLBACK_WINDOW) {
|
|
25
|
-
const e = Math.max(0, Math.min(end ?? len, len));
|
|
26
|
-
const follow = e >= len;
|
|
27
|
-
return { start: Math.max(0, e - win), end: e, pending: len - e, follow };
|
|
28
|
-
}
|
|
29
26
|
export function applyScrollAction(end, len, action) {
|
|
30
27
|
const e = end ?? len;
|
|
31
28
|
switch (action.kind) {
|
|
32
29
|
case "pageUp":
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
30
|
+
// Freeze the commit frontier instead of no-op-ing, so PgUp always
|
|
31
|
+
// engages the held view (new output stops printing below; the live
|
|
32
|
+
// tail stops growing; the terminal stops yanking). Values below the
|
|
33
|
+
// already-committed count hold back future commits only — printed
|
|
34
|
+
// output lives in terminal scrollback and can never retract.
|
|
37
35
|
if (len <= SCROLLBACK_WINDOW)
|
|
38
36
|
return len;
|
|
39
37
|
return Math.max(Math.min(len, SCROLLBACK_WINDOW), e - SCROLL_PAGE_ITEMS);
|
|
@@ -84,30 +82,86 @@ export function renderTranscriptItem(item) {
|
|
|
84
82
|
// Render-count probe for the timer-isolation test: incremented on every
|
|
85
83
|
// TranscriptView render (a 1s timer tick must leave it unchanged).
|
|
86
84
|
export const transcriptRenderProbe = { count: 0 };
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
85
|
+
// Render-count probe for row isolation: incremented per mounted row paint
|
|
86
|
+
// (appending one turn must paint exactly one new row, never the window).
|
|
87
|
+
export const transcriptRowRenderProbe = { count: 0 };
|
|
88
|
+
// Committed turns are immutable once appended (diffs attach pre-commit in
|
|
89
|
+
// onToolActivity, never post-append), and keys stay global (`turn-${idx}`),
|
|
90
|
+
// so a row whose item identity is unchanged can skip rendering entirely.
|
|
91
|
+
// Custom compare: the body array is rebuilt per TranscriptView render with
|
|
92
|
+
// fresh wrappers around the SAME turn refs — shallow compare would always
|
|
93
|
+
// miss, hence id + turn/label identity. An unstable render fn falls back to
|
|
94
|
+
// today's behavior (re-render) rather than going stale.
|
|
95
|
+
function transcriptRowEqual(a, b) {
|
|
96
|
+
return (a.render === b.render &&
|
|
97
|
+
a.item.id === b.item.id &&
|
|
98
|
+
a.item.turn === b.item.turn &&
|
|
99
|
+
a.item.label === b.item.label);
|
|
100
|
+
}
|
|
101
|
+
const TranscriptRow = React.memo(function TranscriptRow({ item, render }) {
|
|
102
|
+
transcriptRowRenderProbe.count += 1;
|
|
103
|
+
return _jsx(React.Fragment, { children: render(item) });
|
|
104
|
+
}, transcriptRowEqual);
|
|
105
|
+
// Monotonic static admission: convert record turns [from, to) into Static
|
|
106
|
+
// items, pairing adjacent [audit label, error detail] within the batch and
|
|
107
|
+
// permanently skipping hidden thinking turns. ALWAYS returns next ===
|
|
108
|
+
// clamped `to` (even when everything skips) so the frontier only moves
|
|
109
|
+
// forward — shrinking or reordering same-identity items would misalign
|
|
110
|
+
// Ink's append-only Static buffer and duplicate terminal scrollback.
|
|
111
|
+
// List replacements (/clear, /resume, /rewind) bump clearGen instead, which
|
|
112
|
+
// resets the buffer via the Static identity below.
|
|
113
|
+
export function admitStaticBatch(turns, from, to, showThinking) {
|
|
114
|
+
const end = Math.max(from, Math.min(to, turns.length));
|
|
115
|
+
const items = [];
|
|
116
|
+
let idx = from;
|
|
117
|
+
while (idx < end) {
|
|
98
118
|
const turn = turns[idx];
|
|
99
|
-
if (turn.thinking === true && !showThinking)
|
|
119
|
+
if (turn.thinking === true && !showThinking) {
|
|
120
|
+
idx += 1;
|
|
100
121
|
continue;
|
|
101
|
-
|
|
122
|
+
}
|
|
123
|
+
const next = idx + 1 < end ? turns[idx + 1] : undefined;
|
|
102
124
|
if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
|
|
103
|
-
|
|
104
|
-
idx +=
|
|
125
|
+
items.push({ id: `turn-${idx}`, turn: next, label: turn });
|
|
126
|
+
idx += 2;
|
|
105
127
|
continue;
|
|
106
128
|
}
|
|
107
|
-
|
|
129
|
+
items.push({ id: `turn-${idx}`, turn });
|
|
130
|
+
idx += 1;
|
|
131
|
+
}
|
|
132
|
+
return { items, next: end };
|
|
133
|
+
}
|
|
134
|
+
export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, held, showThinking = true, }) {
|
|
135
|
+
transcriptRenderProbe.count += 1;
|
|
136
|
+
const render = renderItem ?? renderTranscriptItem;
|
|
137
|
+
const frontier = end ?? turns.length;
|
|
138
|
+
// Committed static state: full reset on clearGen (list replacements bump
|
|
139
|
+
// it — replacements must never reuse the buffer), suffix-only advance
|
|
140
|
+
// otherwise (setState-during-render derived-state pattern; the extra pass
|
|
141
|
+
// runs only when genuinely new items commit, never on ticks/keystrokes).
|
|
142
|
+
const [committed, setCommitted] = React.useState(() => {
|
|
143
|
+
const base = clearGen === 0 ? [{ id: "banner" }] : [];
|
|
144
|
+
const batch = admitStaticBatch(turns, 0, frontier, showThinking);
|
|
145
|
+
return { gen: clearGen, items: [...base, ...batch.items], next: batch.next };
|
|
146
|
+
});
|
|
147
|
+
if (committed.gen !== clearGen) {
|
|
148
|
+
const base = clearGen === 0 ? [{ id: "banner" }] : [];
|
|
149
|
+
const batch = admitStaticBatch(turns, 0, end ?? turns.length, showThinking);
|
|
150
|
+
setCommitted({ gen: clearGen, items: [...base, ...batch.items], next: batch.next });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const batch = admitStaticBatch(turns, committed.next, frontier, showThinking);
|
|
154
|
+
if (batch.items.length > 0 || batch.next !== committed.next) {
|
|
155
|
+
setCommitted({
|
|
156
|
+
gen: clearGen,
|
|
157
|
+
items: [...committed.items, ...batch.items],
|
|
158
|
+
next: batch.next,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
108
161
|
}
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
// Backlog below the committed frontier (frozen appends, not yet printed).
|
|
163
|
+
const pending = turns.length - committed.next;
|
|
164
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: committed.items, children: (item) => _jsx(TranscriptRow, { item: item, render: render }, item.id) }, clearGen), pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
|
|
111
165
|
});
|
|
112
166
|
// Startup banner: the ATOM block-letter art, rendered once at launch inside
|
|
113
167
|
// <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
|