atom-agent 0.3.0 → 1.1.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 +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "./theme.js";
|
|
4
|
+
export function PickerShell({ title, borderColor = theme.border.picker, children, }) {
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: borderColor, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: title }), children] }));
|
|
6
|
+
}
|
|
7
|
+
export function PickerMoreAbove({ count }) {
|
|
8
|
+
if (count <= 0)
|
|
9
|
+
return null;
|
|
10
|
+
return (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " ", count, " more"] }));
|
|
11
|
+
}
|
|
12
|
+
export function PickerMoreBelow({ count }) {
|
|
13
|
+
if (count <= 0)
|
|
14
|
+
return null;
|
|
15
|
+
return (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreBelow, " ", count, " more"] }));
|
|
16
|
+
}
|
|
17
|
+
// Visible window for a picker: at most `visible` rows, scrolled so the
|
|
18
|
+
// highlight stays visible (centered while scrolling, pinned at both ends).
|
|
19
|
+
// Pure — the frame never grows past the window no matter how many rows
|
|
20
|
+
// the list holds. Shared by every windowed popup (pickers, slash menu,
|
|
21
|
+
// palette).
|
|
22
|
+
export const MODEL_PICKER_VISIBLE = 10;
|
|
23
|
+
export function pickerWindow(total, highlight, visible = MODEL_PICKER_VISIBLE) {
|
|
24
|
+
if (total <= visible)
|
|
25
|
+
return { start: 0, end: total };
|
|
26
|
+
const h = Math.max(0, Math.min(highlight, total - 1));
|
|
27
|
+
const start = Math.max(0, Math.min(h - Math.floor(visible / 2), total - visible));
|
|
28
|
+
return { start, end: start + visible };
|
|
29
|
+
}
|
|
30
|
+
export function PickerRow({ highlighted, highlightColor = theme.color.selection, children, }) {
|
|
31
|
+
return (_jsxs(Text, { color: highlighted ? highlightColor : undefined, children: [highlighted ? `${theme.symbol.select} ` : theme.spacing.rowIndent, children] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Side-by-side diff view for write/edit results: BEFORE pane (left) vs
|
|
3
|
+
// AFTER pane (right), aligned rows, preserved line numbers.
|
|
4
|
+
//
|
|
5
|
+
// Requirements it answers (presentation only — data comes from the
|
|
6
|
+
// existing approve-time preview / commit slot, tool behavior untouched):
|
|
7
|
+
// - left = original, right = result; additions right, removals left,
|
|
8
|
+
// context on both sides; paired changes share ONE row so corresponding
|
|
9
|
+
// lines align; changed regions pop via the existing word-background +
|
|
10
|
+
// add/del line-number treatment; syntax colors reused per cell.
|
|
11
|
+
// - hunks only (configurable context in the engine) — never whole files.
|
|
12
|
+
// - width-aware: panes split the measured terminal (useStdout, same
|
|
13
|
+
// pattern as App's termColumns); long lines truncate per pane with …
|
|
14
|
+
// (code-point safe); below NARROW_COLUMNS the view degrades to the
|
|
15
|
+
// stacked unified DiffView instead of destroying the layout.
|
|
16
|
+
// - computed once per mount (useMemo, keyed on inputs + pane width) and
|
|
17
|
+
// capped (maxRows + trailer) — never recomputed per tick, never floods.
|
|
18
|
+
// All paint comes from ui/theme tokens.
|
|
19
|
+
import React from "react";
|
|
20
|
+
import { Box, Text } from "ink";
|
|
21
|
+
import { useStdout } from "ink";
|
|
22
|
+
import { computeSideBySide, wordRuns } from "./diff.js";
|
|
23
|
+
import { DiffView, LineBody } from "./diff-view.js";
|
|
24
|
+
import { theme } from "./theme.js";
|
|
25
|
+
// Below this width two panes cannot breathe — stack unified instead.
|
|
26
|
+
export const SBS_NARROW_COLUMNS = 70;
|
|
27
|
+
// Committed-transcript cap (rows, context + change): the scrollback shows
|
|
28
|
+
// the reviewable head; the file on disk is the whole truth. The engine
|
|
29
|
+
// still truncates past 400 changed lines with its own notice.
|
|
30
|
+
export const TRANSCRIPT_DIFF_MAX_LINES = 120;
|
|
31
|
+
function truncateTo(s, width) {
|
|
32
|
+
const chars = [...s];
|
|
33
|
+
if (chars.length <= width)
|
|
34
|
+
return s;
|
|
35
|
+
if (width < 4)
|
|
36
|
+
return "";
|
|
37
|
+
return chars.slice(0, width - 1).join("") + theme.symbol.ellipsis;
|
|
38
|
+
}
|
|
39
|
+
// Fit engine rows to a pane content width: truncate cell texts (with …)
|
|
40
|
+
// and re-derive word runs on the truncated pair so offsets always tile
|
|
41
|
+
// the displayed text. Runs once per mount/width — never per tick.
|
|
42
|
+
function fitRows(rows, contentW) {
|
|
43
|
+
return rows.map((r) => {
|
|
44
|
+
if (r.kind === "context") {
|
|
45
|
+
return {
|
|
46
|
+
kind: "context",
|
|
47
|
+
left: { no: r.oldNo, text: truncateTo(r.text, contentW) },
|
|
48
|
+
right: { no: r.newNo, text: truncateTo(r.text, contentW) },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const tOld = r.oldText !== null ? truncateTo(r.oldText, contentW) : null;
|
|
52
|
+
const tNew = r.newText !== null ? truncateTo(r.newText, contentW) : null;
|
|
53
|
+
let oldRuns = [];
|
|
54
|
+
let newRuns = [];
|
|
55
|
+
if (tOld !== null && tNew !== null) {
|
|
56
|
+
const w = wordRuns(tOld, tNew);
|
|
57
|
+
oldRuns = w.del;
|
|
58
|
+
newRuns = w.add;
|
|
59
|
+
}
|
|
60
|
+
else if (tOld !== null) {
|
|
61
|
+
oldRuns = tOld === "" ? [] : [{ text: tOld, changed: false }];
|
|
62
|
+
}
|
|
63
|
+
else if (tNew !== null) {
|
|
64
|
+
newRuns = tNew === "" ? [] : [{ text: tNew, changed: false }];
|
|
65
|
+
}
|
|
66
|
+
const changed = oldRuns.some((x) => x.changed) ||
|
|
67
|
+
newRuns.some((x) => x.changed) ||
|
|
68
|
+
(r.oldText === null) !== (r.newText === null);
|
|
69
|
+
return {
|
|
70
|
+
kind: "change",
|
|
71
|
+
changed,
|
|
72
|
+
left: tOld !== null ? { no: r.oldNo, text: tOld, runs: oldRuns } : null,
|
|
73
|
+
right: tNew !== null ? { no: r.newNo, text: tNew, runs: newRuns } : null,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function padEnd(s, width) {
|
|
78
|
+
const len = [...s].length;
|
|
79
|
+
if (len >= width)
|
|
80
|
+
return s;
|
|
81
|
+
return s + " ".repeat(width - len);
|
|
82
|
+
}
|
|
83
|
+
function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, columns, }) {
|
|
84
|
+
let stdoutCols;
|
|
85
|
+
try {
|
|
86
|
+
stdoutCols = useStdout()?.stdout?.columns;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
stdoutCols = undefined;
|
|
90
|
+
}
|
|
91
|
+
const totalW = columns ?? stdoutCols ?? 100;
|
|
92
|
+
const sbs = React.useMemo(() => computeSideBySide(oldText, newText), [oldText, newText]);
|
|
93
|
+
if (sbs.kind === "same") {
|
|
94
|
+
return _jsx(Text, { dimColor: true, children: "(no changes \u2014 file unchanged)" });
|
|
95
|
+
}
|
|
96
|
+
if (sbs.kind === "binary") {
|
|
97
|
+
return _jsx(Text, { dimColor: true, children: "binary file changed" });
|
|
98
|
+
}
|
|
99
|
+
if (sbs.kind === "skipped") {
|
|
100
|
+
return _jsx(Text, { dimColor: true, children: sbs.reason });
|
|
101
|
+
}
|
|
102
|
+
// Graceful narrow-terminal degrade: stacked unified keeps every char
|
|
103
|
+
// instead of crushing two panes into unreadable slivers.
|
|
104
|
+
if (totalW < SBS_NARROW_COLUMNS) {
|
|
105
|
+
return _jsx(DiffView, { oldText: oldText, newText: newText, lang: lang, maxLines: maxRows });
|
|
106
|
+
}
|
|
107
|
+
const sep = ` ${theme.symbol.bar} `;
|
|
108
|
+
const paneW = Math.max(20, Math.floor((totalW - sep.length) / 2));
|
|
109
|
+
let maxNo = 0;
|
|
110
|
+
for (const r of sbs.rows) {
|
|
111
|
+
if (r.kind === "context")
|
|
112
|
+
maxNo = Math.max(maxNo, r.oldNo, r.newNo);
|
|
113
|
+
else {
|
|
114
|
+
if (r.oldNo !== null)
|
|
115
|
+
maxNo = Math.max(maxNo, r.oldNo);
|
|
116
|
+
if (r.newNo !== null)
|
|
117
|
+
maxNo = Math.max(maxNo, r.newNo);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const numW = String(Math.max(maxNo, 1)).length;
|
|
121
|
+
const contentW = Math.max(8, paneW - numW - 1);
|
|
122
|
+
const view = React.useMemo(() => fitRows(sbs.rows, contentW),
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
[sbs, contentW]);
|
|
125
|
+
const shown = view.slice(0, maxRows);
|
|
126
|
+
const overflow = Math.max(0, view.length - shown.length);
|
|
127
|
+
const renderCell = (no, body, opts) => {
|
|
128
|
+
const num = no === null ? " ".repeat(numW) : padEnd(String(no), numW);
|
|
129
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: opts.numColor, dimColor: opts.numColor === undefined || opts.dim, children: [num, " "] }), body] }));
|
|
130
|
+
};
|
|
131
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [sbs.isNewFile ? "new file " : "", "+", sbs.adds, " \u2212", sbs.dels] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: padEnd("BEFORE", paneW) }), _jsx(Text, { dimColor: true, children: sep }), _jsx(Text, { bold: true, children: "AFTER" })] }), shown.map((r, k) => {
|
|
132
|
+
if (r.kind === "context") {
|
|
133
|
+
return (_jsxs(Text, { children: [renderCell(r.left.no, _jsx(Text, { dimColor: true, children: r.left.text }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), renderCell(r.right.no, _jsx(Text, { dimColor: true, children: r.right.text }), { dim: true })] }, k));
|
|
134
|
+
}
|
|
135
|
+
const leftNumColor = r.left !== null && r.changed ? theme.color.toolError : undefined;
|
|
136
|
+
const rightNumColor = r.right !== null && r.changed ? theme.color.success : undefined;
|
|
137
|
+
return (_jsxs(Text, { children: [r.left !== null
|
|
138
|
+
? renderCell(r.left.no, _jsx(LineBody, { lineText: r.left.text, runs: r.left.runs, base: "del", lang: lang }), { numColor: leftNumColor, dim: !r.changed })
|
|
139
|
+
: renderCell(null, _jsx(Text, { children: padEnd("", contentW) }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), r.right !== null
|
|
140
|
+
? renderCell(r.right.no, _jsx(LineBody, { lineText: r.right.text, runs: r.right.runs, base: "add", lang: lang }), { numColor: rightNumColor, dim: !r.changed })
|
|
141
|
+
: renderCell(null, _jsx(Text, { children: padEnd("", contentW) }), { dim: true })] }, k));
|
|
142
|
+
}), overflow > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", overflow, " more row", overflow === 1 ? "" : "s"] }) : null, sbs.truncated ? _jsx(Text, { dimColor: true, children: "(diff truncated at 400 changed lines)" }) : null] }));
|
|
143
|
+
}
|
|
144
|
+
export const SideBySideDiffView = React.memo(SideBySideInner);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Status bar leaf: the sole info bar, state-prioritized and quiet.
|
|
3
|
+
// - idle: provider/model │ token │ cwd[:branch] │ reasoning │ mode.
|
|
4
|
+
// Labels are positional (no `provider:` prefixes); mode/trust show always
|
|
5
|
+
// (pinned), cwd shortens, branch only for git repos.
|
|
6
|
+
// - busy: activity │ elapsed │ token │ reasoning │ mode │ esc-hint (+waiting/approval flags).
|
|
7
|
+
// Provider/model/cwd drop while working — the activity, the
|
|
8
|
+
// clock, context pressure, effort, and the pinned mode are what matter mid-turn.
|
|
9
|
+
// All paint comes from ui/theme tokens. The token segment formatter lives
|
|
10
|
+
// in context-windows (its only surface).
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { Box, Text } from "ink";
|
|
13
|
+
import { formatTokenSegment } from "../context-windows.js";
|
|
14
|
+
import { theme } from "./theme.js";
|
|
15
|
+
// ~/… collapse + tail-cut: informative, never a full scroll of nesting.
|
|
16
|
+
// Further shrinking for tight widths goes through shrinkTo below (the bar
|
|
17
|
+
// measures first and only renders what fits).
|
|
18
|
+
export function shortenCwd(cwd, home, max = 20) {
|
|
19
|
+
const short = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
20
|
+
if (short.length <= max)
|
|
21
|
+
return short;
|
|
22
|
+
return `…/${short.slice(-(max - 3))}`;
|
|
23
|
+
}
|
|
24
|
+
// Shrink text to n chars max for tight widths (`…/tail` keeps the
|
|
25
|
+
// meaningful end). n < 4 yields "" (caller drops the segment instead).
|
|
26
|
+
export function shrinkTo(s, n) {
|
|
27
|
+
if (s.length <= n)
|
|
28
|
+
return s;
|
|
29
|
+
if (n < 4)
|
|
30
|
+
return "";
|
|
31
|
+
return `…/${s.slice(-(n - 3))}`;
|
|
32
|
+
}
|
|
33
|
+
// Render-count probe for the flicker tests: incremented on every StatusBar
|
|
34
|
+
// render (same-props parent churn — token paints, keystrokes, unrelated
|
|
35
|
+
// ticks — must skip it; only changed props repaint).
|
|
36
|
+
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, }) {
|
|
38
|
+
statusBarRenderProbe.count += 1;
|
|
39
|
+
const bar = theme.symbol.bar;
|
|
40
|
+
if (!busy) {
|
|
41
|
+
const token = formatTokenSegment(usageTotals, model, contextLoad);
|
|
42
|
+
const trust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
43
|
+
// Measure-first layout: the location (cwd + branch) flexes so the whole
|
|
44
|
+
// line always fits `columns`. Fixed segments never shrink (wrapping
|
|
45
|
+
// would split `mode: X` needles across lines); the location yields in
|
|
46
|
+
// order: branch → cwd tail → the whole segment.
|
|
47
|
+
const tail = `reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${trust}`;
|
|
48
|
+
const baseLen = `${provider}/${model} ${bar} ${token} ${bar} ${bar} ${tail}`.length;
|
|
49
|
+
const avail = columns - baseLen;
|
|
50
|
+
let loc = null;
|
|
51
|
+
if (cwd) {
|
|
52
|
+
const branchPart = branch ? ` : ${branch}` : "";
|
|
53
|
+
if (3 + cwd.length + branchPart.length <= avail) {
|
|
54
|
+
loc = `${cwd}${branchPart}`;
|
|
55
|
+
}
|
|
56
|
+
else if (3 + cwd.length <= avail) {
|
|
57
|
+
loc = cwd;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const shrunk = shrinkTo(cwd, avail - 3);
|
|
61
|
+
loc = shrunk ? shrunk : null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return (_jsx(Box, { marginTop: theme.spacing.statusMarginTop, children: _jsxs(Text, { dimColor: true, children: [provider, "/", model, " ", bar, " ", token, loc ? (_jsxs(_Fragment, { children: [" ", bar, " ", loc] })) : null, " ", bar, " reasoning: ", reasoningDisplay, " ", bar, " mode: ", mode, trust ? "+trust" : null] }) }));
|
|
65
|
+
}
|
|
66
|
+
// Busy layout prioritizes activity + clock + interrupt hint; the mode
|
|
67
|
+
// stays pinned (it used to vanish while working), and the reasoning
|
|
68
|
+
// effort stays visible (it used to vanish while working). The activity text
|
|
69
|
+
// shrinks to fit so `esc stops` never wraps away.
|
|
70
|
+
const busyTrust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
71
|
+
const busyFixed = ` ${bar} ${elapsedSecs}s ${bar} ${formatTokenSegment(usageTotals, model, contextLoad)} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust} ${bar} esc stops`;
|
|
72
|
+
const busyAvail = columns - busyFixed.length - 2;
|
|
73
|
+
const activityText = shrinkTo(activity ?? phaseLabel, Math.max(0, busyAvail));
|
|
74
|
+
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
|
+
});
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ATOM TUI design tokens: the single source of visual truth.
|
|
2
|
+
//
|
|
3
|
+
// Every color, glyph, separator, border, and spacing value in the interface
|
|
4
|
+
// lives here. Components reference these tokens — never string literals —
|
|
5
|
+
// so the whole TUI can be re-skinned by editing this file alone, and later
|
|
6
|
+
// polish chunks change values here instead of hunting call sites.
|
|
7
|
+
//
|
|
8
|
+
// Density rules (terminal space is scarce):
|
|
9
|
+
// - No decorative boxes: only the input, pickers, and modals are framed.
|
|
10
|
+
// - The transcript is frameless text; hierarchy comes from speaker labels,
|
|
11
|
+
// dimming, and one blank line between turns — never extra chrome.
|
|
12
|
+
// - `muted` is implemented as Ink `dimColor` (terminal-dimmed default fg),
|
|
13
|
+
// NOT as gray paint: it adapts to light/dark terminals. Literal gray
|
|
14
|
+
// (`color.mutedPaint`) is reserved for block glyphs (cursors) that need a
|
|
15
|
+
// fixed shade to read as a shape.
|
|
16
|
+
//
|
|
17
|
+
// Identities (ATOM's own, not borrowed):
|
|
18
|
+
// - Speaker labels: `you>` (cyan) vs `ATOM>` (magenta).
|
|
19
|
+
// - Selection marker: `❯` + highlight color; unselected rows indent two
|
|
20
|
+
// spaces so lists align without bullets.
|
|
21
|
+
// - Live activity: `◌` (tool running), `💭` (thinking), `▍`/`█` (cursors).
|
|
22
|
+
// - Status segments join with `·`; name/description rows join with `—`.
|
|
23
|
+
export const theme = {
|
|
24
|
+
color: {
|
|
25
|
+
// Base surfaces: inherit the terminal (no paint) — the TUI never sets a
|
|
26
|
+
// background, so light and dark terminals both work.
|
|
27
|
+
background: undefined,
|
|
28
|
+
foreground: undefined,
|
|
29
|
+
// Primary reading text: terminal default, emphasized with bold (titles,
|
|
30
|
+
// speaker labels), never with a hue.
|
|
31
|
+
primary: undefined,
|
|
32
|
+
// Secondary/muted text: Ink dimColor mechanism (see note above).
|
|
33
|
+
// `mutedPaint` is the fixed gray reserved for cursor glyphs.
|
|
34
|
+
mutedPaint: "gray",
|
|
35
|
+
// Interactive selection (picker rows, approval highlight).
|
|
36
|
+
selection: "green",
|
|
37
|
+
menuSelection: "cyan",
|
|
38
|
+
questionSelection: "magenta",
|
|
39
|
+
// Speaker identities.
|
|
40
|
+
user: "cyan",
|
|
41
|
+
assistant: "magenta",
|
|
42
|
+
inputPrompt: "cyan",
|
|
43
|
+
// Live activity (busy phase segment in the status bar).
|
|
44
|
+
activity: "yellow",
|
|
45
|
+
// Tool transcript lines: dim default text; red only on failure.
|
|
46
|
+
tool: undefined,
|
|
47
|
+
toolError: "red",
|
|
48
|
+
// Outcomes + permission surfaces.
|
|
49
|
+
success: "green",
|
|
50
|
+
warning: "yellow",
|
|
51
|
+
error: "red",
|
|
52
|
+
permission: "yellow",
|
|
53
|
+
// Reserved for the streaming-markdown chunk: code frames + links + headings.
|
|
54
|
+
code: "green",
|
|
55
|
+
link: "cyan",
|
|
56
|
+
heading: undefined, // bold, no hue
|
|
57
|
+
// Diff-body syntax colors (ui/highlight): Monokai-ish hues that read
|
|
58
|
+
// on dark and light terminals. Comments stay dim (no hue — same rule
|
|
59
|
+
// as muted text); plain code inherits the line paint.
|
|
60
|
+
synKeyword: "magenta",
|
|
61
|
+
synString: "yellow",
|
|
62
|
+
synNumber: "cyan",
|
|
63
|
+
},
|
|
64
|
+
border: {
|
|
65
|
+
style: "round",
|
|
66
|
+
input: "gray",
|
|
67
|
+
picker: "green",
|
|
68
|
+
menu: "cyan",
|
|
69
|
+
panel: "cyan",
|
|
70
|
+
approval: "yellow",
|
|
71
|
+
question: "magenta",
|
|
72
|
+
},
|
|
73
|
+
spacing: {
|
|
74
|
+
// Unselected picker rows indent to align with `❯ ` selected rows.
|
|
75
|
+
rowIndent: " ",
|
|
76
|
+
// Code-block body indent (no boxes around code — indentation only).
|
|
77
|
+
codeIndent: " ",
|
|
78
|
+
pickerPadX: 1,
|
|
79
|
+
// Breathing room: one blank line after each committed turn; the live
|
|
80
|
+
// tail floats with vertical margin; the status bar sits one line below.
|
|
81
|
+
turnGap: 1,
|
|
82
|
+
liveTailMarginY: 1,
|
|
83
|
+
statusMarginTop: 1,
|
|
84
|
+
},
|
|
85
|
+
symbol: {
|
|
86
|
+
select: "❯",
|
|
87
|
+
bullet: "•",
|
|
88
|
+
quoteBar: "│",
|
|
89
|
+
moreAbove: "↑",
|
|
90
|
+
moreBelow: "↓",
|
|
91
|
+
separator: "·",
|
|
92
|
+
descSeparator: "—",
|
|
93
|
+
// Status-bar segment divider (structural, quiet). Inline joins elsewhere
|
|
94
|
+
// keep `·`.
|
|
95
|
+
bar: "│",
|
|
96
|
+
// Expanded-view divider unit (repeated for the rule line — a divider,
|
|
97
|
+
// never a frame).
|
|
98
|
+
rule: "─",
|
|
99
|
+
ellipsis: "…",
|
|
100
|
+
running: "◌",
|
|
101
|
+
thinking: "💭",
|
|
102
|
+
// Working-state glyphs: open circle = unsettled (thinking), filled
|
|
103
|
+
// circle = engaged (tool executing). Stable by design — liveness reads
|
|
104
|
+
// from ticking elapsed seconds, not animation (see ui/activity).
|
|
105
|
+
workThinking: "◐",
|
|
106
|
+
workTool: "◉",
|
|
107
|
+
// Attention marker for permission + warning surfaces (mirrors the
|
|
108
|
+
// loop's `⚠ ` warning prefix).
|
|
109
|
+
warningMark: "⚠",
|
|
110
|
+
// The loop's tool-audit marker: committed call rows keep `⚙ name target`
|
|
111
|
+
// byte-identical (tests + help pin the text); ToolLine keys its slow-run
|
|
112
|
+
// suffix off this same marker.
|
|
113
|
+
toolMark: "⚙",
|
|
114
|
+
cursorBar: "▍",
|
|
115
|
+
cursorBlock: "█",
|
|
116
|
+
inputPrompt: "›",
|
|
117
|
+
keyMask: "•",
|
|
118
|
+
keyPresent: "✓",
|
|
119
|
+
taskDone: "✅",
|
|
120
|
+
taskActive: "🔧",
|
|
121
|
+
// Pending means "not started yet" — an open circle (never ❌, which
|
|
122
|
+
// reads as failed/denied). Matches the circle language of the
|
|
123
|
+
// working-state glyphs (◌ unsettled / ◉ engaged).
|
|
124
|
+
taskPending: "○",
|
|
125
|
+
speakerUser: "you>",
|
|
126
|
+
speakerAssistant: "ATOM>",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Live session checklist panel (TodoWrite mirror). Prop-driven; returns
|
|
3
|
+
// null when empty. Mounted below the transcript, fed by a checklist snapshot.
|
|
4
|
+
// Paint from ui/theme tokens — no literal colors or glyphs here.
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Box, Text } from "ink";
|
|
7
|
+
import { theme } from "./theme.js";
|
|
8
|
+
// Live session checklist (Claude-Code-style TodoWrite panel). Mounted in
|
|
9
|
+
// the live area below the transcript (NOT in <Static> scrollback) and fed
|
|
10
|
+
// by a snapshot the loop refreshes after every todowrite/todo_update call,
|
|
11
|
+
// so the in-progress row — shown with its activeForm when present — always
|
|
12
|
+
// answers "what is the model doing right now". Returns null when empty.
|
|
13
|
+
// Render-count probe for the flicker tests: same-props parent churn must
|
|
14
|
+
// skip the panel (it only changes when the loop commits todo activity).
|
|
15
|
+
export const todoPanelRenderProbe = { count: 0 };
|
|
16
|
+
export const TodoPanel = React.memo(function TodoPanel({ items }) {
|
|
17
|
+
todoPanelRenderProbe.count += 1;
|
|
18
|
+
if (items.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
const done = items.filter((t) => t.status === "completed").length;
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.panel, paddingX: theme.spacing.pickerPadX, marginTop: theme.spacing.turnGap, children: [_jsxs(Text, { bold: true, children: ["Tasks ", done, "/", items.length] }), items.map((t, i) => {
|
|
22
|
+
const mark = t.status === "completed"
|
|
23
|
+
? theme.symbol.taskDone
|
|
24
|
+
: t.status === "in_progress"
|
|
25
|
+
? theme.symbol.taskActive
|
|
26
|
+
: theme.symbol.taskPending;
|
|
27
|
+
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
|
28
|
+
return (_jsxs(Text, { dimColor: t.status === "completed", children: [mark, " ", label, t.priority ? ` (${t.priority})` : ""] }, `${i}-${t.content}`));
|
|
29
|
+
})] }));
|
|
30
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "./theme.js";
|
|
4
|
+
export const MAX_TOOL_RECORDS = 50;
|
|
5
|
+
export const STORE_CHARS = 32768;
|
|
6
|
+
export const VIEWPORT_LINES = 20;
|
|
7
|
+
export const LIST_WINDOW = 15;
|
|
8
|
+
export function createToolRecord(id, label, result, isError, ms) {
|
|
9
|
+
const text = result ?? "";
|
|
10
|
+
const truncated = text.length > STORE_CHARS;
|
|
11
|
+
const stored = truncated ? text.slice(0, STORE_CHARS) : text;
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
label,
|
|
15
|
+
result: stored,
|
|
16
|
+
truncated,
|
|
17
|
+
isError,
|
|
18
|
+
ms,
|
|
19
|
+
lineCount: stored.length === 0 ? 0 : stored.split("\n").length,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// Centered window over a list: {slice, start, above, below}. Pure — the
|
|
23
|
+
// panel and its tests share it.
|
|
24
|
+
export function windowedList(items, index, size) {
|
|
25
|
+
if (items.length <= size)
|
|
26
|
+
return { slice: items, start: 0, above: 0, below: 0 };
|
|
27
|
+
const half = Math.floor(size / 2);
|
|
28
|
+
let start = Math.max(0, Math.min(index - half, items.length - size));
|
|
29
|
+
return {
|
|
30
|
+
slice: items.slice(start, start + size),
|
|
31
|
+
start,
|
|
32
|
+
above: start,
|
|
33
|
+
below: items.length - (start + size),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function formatDur(ms) {
|
|
37
|
+
return ` ${theme.symbol.separator} ${Math.max(1, Math.round(ms / 1000))}s`;
|
|
38
|
+
}
|
|
39
|
+
export function InspectorPanel({ records, index, expanded, scroll }) {
|
|
40
|
+
const sel = Math.max(0, Math.min(index, records.length - 1));
|
|
41
|
+
const rec = records[sel];
|
|
42
|
+
if (!rec)
|
|
43
|
+
return null;
|
|
44
|
+
if (!expanded) {
|
|
45
|
+
const win = windowedList(records, sel, LIST_WINDOW);
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Tool outputs \u2014 select to inspect (Enter expands, Esc closes):" }), win.above > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " ", win.above, " more"] })) : null, win.slice.map((r, k) => {
|
|
47
|
+
const i = win.start + k;
|
|
48
|
+
const hi = i === sel;
|
|
49
|
+
return (_jsxs(Text, { color: hi ? theme.color.selection : r.isError ? theme.color.toolError : undefined, children: [hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, r.isError ? "✕ " : "", r.label, r.ms >= 2000 ? formatDur(r.ms) : ""] }, r.id));
|
|
50
|
+
}), win.below > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreBelow, " ", win.below, " more"] })) : null, _jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " move \u00B7 Enter expands \u00B7 Esc closes \u00B7 Ctrl+O closes"] })] }));
|
|
51
|
+
}
|
|
52
|
+
const lines = rec.result.split("\n");
|
|
53
|
+
const total = lines.length;
|
|
54
|
+
const maxOffset = Math.max(0, total - VIEWPORT_LINES);
|
|
55
|
+
const off = Math.max(0, Math.min(scroll, maxOffset));
|
|
56
|
+
const view = lines.slice(off, off + VIEWPORT_LINES);
|
|
57
|
+
const rule = theme.symbol.rule.repeat(32);
|
|
58
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: rec.isError ? theme.color.toolError : theme.color.success, children: rec.isError ? "✕ " : "✓ " }), rec.label, rec.ms >= 2000 ? formatDur(rec.ms) : "", _jsxs(Text, { dimColor: true, children: [" ", theme.symbol.separator, " ", rec.lineCount, " line", rec.lineCount === 1 ? "" : "s"] })] }), rec.truncated ? (_jsxs(Text, { dimColor: true, children: ["(stored output truncated at ", Math.round(STORE_CHARS / 1024), "KB)"] })) : null, _jsx(Text, { dimColor: true, children: rule }), view.map((ln, k) => (_jsx(Text, { children: ln.length > 0 ? ln : " " }, off + k))), _jsx(Text, { dimColor: true, children: rule }), _jsxs(Text, { dimColor: true, children: [off > 0 ? `${theme.symbol.moreAbove} ${off} more ` : "", theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " scroll \u00B7 PgUp/PgDn jump \u00B7 Enter collapses \u00B7 Esc closes", maxOffset - off > 0 ? ` ${theme.symbol.moreBelow} ${maxOffset - off} more` : ""] })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Transcript leaves: the committed <Static> scrollback, its item renderer,
|
|
3
|
+
// and the startup banner. Prop-driven + memoized (see comments) so App state
|
|
4
|
+
// churn never repaints them. Turn is the display-transcript entry shape.
|
|
5
|
+
// All paint comes from ui/theme tokens — no literal colors or glyphs here.
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
|
+
import { SideBySideDiffView, TRANSCRIPT_DIFF_MAX_LINES } from "./side-by-side.js";
|
|
9
|
+
import { ErrorCard, classifyToolError } from "./errors.js";
|
|
10
|
+
import { MarkdownText, ToolLine } from "./markdown.js";
|
|
11
|
+
import { theme } from "./theme.js";
|
|
12
|
+
// Scrollback viewport: the committed transcript renders as a windowed
|
|
13
|
+
// slice of turns in a live Box (NOT <Static> — Static is append-only with
|
|
14
|
+
// no scroll API, so PgUp/Home/follow modes are impossible on it).
|
|
15
|
+
//
|
|
16
|
+
// Model: E = viewed end index (items visible: (E-WIN, E]). E === turns.length
|
|
17
|
+
// means follow mode — new turns extend the view automatically. Any E < len
|
|
18
|
+
// is manual mode: the view freezes while new turns accumulate below, and a
|
|
19
|
+
// `↓ N new` indicator offers the jump back. Clamping makes list replacement
|
|
20
|
+
// (/clear, /resume, /new) re-follow for free (E > len collapses to len).
|
|
21
|
+
// Banner shows only when the window touches the top.
|
|
22
|
+
export const SCROLLBACK_WINDOW = 300;
|
|
23
|
+
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
|
+
export function applyScrollAction(end, len, action) {
|
|
30
|
+
const e = end ?? len;
|
|
31
|
+
switch (action.kind) {
|
|
32
|
+
case "pageUp":
|
|
33
|
+
// Short sessions (everything fits the window) have no window to move:
|
|
34
|
+
// freeze at the bottom instead of no-op-ing, so PgUp always engages
|
|
35
|
+
// the held view (live output stops growing; the terminal stops
|
|
36
|
+
// yanking). Long sessions move the window up a page, as before.
|
|
37
|
+
if (len <= SCROLLBACK_WINDOW)
|
|
38
|
+
return len;
|
|
39
|
+
return Math.max(Math.min(len, SCROLLBACK_WINDOW), e - SCROLL_PAGE_ITEMS);
|
|
40
|
+
case "pageDown": {
|
|
41
|
+
const next = Math.min(len, e + SCROLL_PAGE_ITEMS);
|
|
42
|
+
return next >= len ? null : next;
|
|
43
|
+
}
|
|
44
|
+
case "home":
|
|
45
|
+
return Math.min(len, SCROLLBACK_WINDOW);
|
|
46
|
+
case "end":
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function isAuditLabel(t) {
|
|
51
|
+
return t.role === "tool" && !t.error && t.content.startsWith(`${theme.symbol.toolMark} `);
|
|
52
|
+
}
|
|
53
|
+
export function renderTranscriptItem(item) {
|
|
54
|
+
if (!item.turn)
|
|
55
|
+
return _jsx(StartupBanner, {}, item.id);
|
|
56
|
+
const t = item.turn;
|
|
57
|
+
const i = item.id;
|
|
58
|
+
// Committed thinking blocks read as quiet annotations (never confused
|
|
59
|
+
// with answers): dim label plus the raw reasoning text, verbatim.
|
|
60
|
+
if (t.thinking === true) {
|
|
61
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " thinking"] }), _jsx(Text, { dimColor: true, children: t.content })] }, i));
|
|
62
|
+
}
|
|
63
|
+
// Conversation turns (user/assistant) breathe: one blank line after each,
|
|
64
|
+
// so the eye lands on the next turn. Tool/status lines stay dense — they
|
|
65
|
+
// read as lightweight annotations woven between turns, not blocks.
|
|
66
|
+
if (t.role === "user") {
|
|
67
|
+
return (_jsx(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: _jsxs(Text, { children: [_jsxs(Text, { color: theme.color.user, bold: true, children: [theme.symbol.speakerUser, " "] }), t.content] }) }, i));
|
|
68
|
+
}
|
|
69
|
+
if (t.role === "tool") {
|
|
70
|
+
const classified = classifyToolError(t, item.label ?? null);
|
|
71
|
+
if (classified) {
|
|
72
|
+
// Paired cards keep the verbatim audit line above the card (pinned
|
|
73
|
+
// `⚙ name target` text for tests/scanning) and name the failure in
|
|
74
|
+
// the card title. Lone details render the card alone. A successful
|
|
75
|
+
// write/edit label swallowed by pairing (success line immediately
|
|
76
|
+
// followed by an error line) keeps its committed diff above the card.
|
|
77
|
+
const labelDiff = item.label?.diff;
|
|
78
|
+
return (_jsxs(React.Fragment, { children: [item.label ? _jsx(ToolLine, { content: item.label.content, ms: item.label.ms }) : null, labelDiff && !item.label?.error ? (_jsx(SideBySideDiffView, { oldText: labelDiff.oldText, newText: labelDiff.newText, lang: labelDiff.lang, maxRows: TRANSCRIPT_DIFF_MAX_LINES })) : null, _jsx(ErrorCard, { classified: classified })] }, i));
|
|
79
|
+
}
|
|
80
|
+
return (_jsxs(React.Fragment, { children: [_jsx(ToolLine, { content: t.content, error: t.error, ms: t.ms }), t.diff && !t.error ? (_jsx(SideBySideDiffView, { oldText: t.diff.oldText, newText: t.diff.newText, lang: t.diff.lang, maxRows: TRANSCRIPT_DIFF_MAX_LINES })) : null] }, i));
|
|
81
|
+
}
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownText, { text: t.content })] }, i));
|
|
83
|
+
}
|
|
84
|
+
// Render-count probe for the timer-isolation test: incremented on every
|
|
85
|
+
// TranscriptView render (a 1s timer tick must leave it unchanged).
|
|
86
|
+
export const transcriptRenderProbe = { count: 0 };
|
|
87
|
+
export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, windowSize, held, showThinking = true, }) {
|
|
88
|
+
transcriptRenderProbe.count += 1;
|
|
89
|
+
const render = renderItem ?? renderTranscriptItem;
|
|
90
|
+
const win = windowSize ?? SCROLLBACK_WINDOW;
|
|
91
|
+
const vp = resolveViewport(turns.length, end, win);
|
|
92
|
+
// Pairing ([audit label, error detail] → one card) runs over the VISIBLE
|
|
93
|
+
// slice only — pairing is positional, and off-window turns never mount.
|
|
94
|
+
// Keys stay global (`turn-${idx}`) so scrolling never remounts rows.
|
|
95
|
+
// Hidden thinking turns are skipped in place (same index stability).
|
|
96
|
+
const body = [];
|
|
97
|
+
for (let idx = vp.start; idx < vp.end; idx++) {
|
|
98
|
+
const turn = turns[idx];
|
|
99
|
+
if (turn.thinking === true && !showThinking)
|
|
100
|
+
continue;
|
|
101
|
+
const next = idx + 1 < vp.end ? turns[idx + 1] : undefined;
|
|
102
|
+
if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
|
|
103
|
+
body.push({ id: `turn-${idx}`, turn: next, label: turn });
|
|
104
|
+
idx += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
body.push({ id: `turn-${idx}`, turn });
|
|
108
|
+
}
|
|
109
|
+
const items = clearGen === 0 && vp.start === 0 ? [{ id: "banner" }, ...body] : body;
|
|
110
|
+
return (_jsxs(Box, { flexDirection: "column", children: [items.map((item) => (_jsx(React.Fragment, { children: render(item) }, item.id))), vp.pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", vp.pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
|
|
111
|
+
});
|
|
112
|
+
// Startup banner: the ATOM block-letter art, rendered once at launch inside
|
|
113
|
+
// <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
|
|
114
|
+
// ATOM (Unicode box-drawing — needs a monospace font with box-drawing
|
|
115
|
+
// support, which Windows Terminal / ConHost / most terminals have). The art
|
|
116
|
+
// is the whole banner: the footer status line is the sole info bar, so no
|
|
117
|
+
// hint lines live here.
|
|
118
|
+
export const ATOM_ART = [
|
|
119
|
+
" █████╗ ████████╗ ██████╗ ███╗ ███╗",
|
|
120
|
+
"██╔══██╗╚══██╔══╝██╔═══██╗████╗ ████║",
|
|
121
|
+
"███████║ ██║ ██║ ██║██╔████╔██║",
|
|
122
|
+
"██╔══██║ ██║ ██║ ██║██║╚██╔╝██║",
|
|
123
|
+
"██║ ██║ ██║ ╚██████╔╝██║ ╚═╝ ██║",
|
|
124
|
+
"╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝",
|
|
125
|
+
];
|
|
126
|
+
export function StartupBanner() {
|
|
127
|
+
return (_jsx(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: ATOM_ART.map((line, i) => (_jsx(Text, { color: theme.color.user, bold: true, children: line }, i))) }));
|
|
128
|
+
}
|