atom-agent 1.0.0 → 1.2.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 +62 -2
- package/README.md +17 -16
- package/dist/App.js +1010 -77
- package/dist/adapters.js +108 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +182 -0
- package/dist/agent/loop.js +781 -329
- package/dist/agent/normalize.js +151 -0
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -12
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +214 -0
- package/dist/tools/filesystem.js +43 -3
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +80 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +147 -80
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +26 -5
- package/dist/tools/todo.js +1 -1
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +3 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +117 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- 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/modals.js +22 -5
- package/dist/ui/palette.js +12 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +105 -39
- package/dist/zen.js +97 -20
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const C_KEYWORDS = new Set("break case catch class const continue debugger default delete do else enum export extends finally for function if implements import interface let new return static super switch this throw try typeof var void while with yield async await".split(" "));
|
|
2
|
+
const PY_KEYWORDS = new Set("def class return if elif else for while in is not and or import from as try except finally with lambda pass break continue raise True False None async await".split(" "));
|
|
3
|
+
const SH_KEYWORDS = new Set("if then else elif fi for while do done case esac function return exit echo local export readonly".split(" "));
|
|
4
|
+
function keywordsFor(lang) {
|
|
5
|
+
if (lang === "c")
|
|
6
|
+
return C_KEYWORDS;
|
|
7
|
+
if (lang === "py")
|
|
8
|
+
return PY_KEYWORDS;
|
|
9
|
+
if (lang === "sh")
|
|
10
|
+
return SH_KEYWORDS;
|
|
11
|
+
return null; // "data": no keywords
|
|
12
|
+
}
|
|
13
|
+
function commentStyle(lang) {
|
|
14
|
+
if (lang === "c")
|
|
15
|
+
return "slash";
|
|
16
|
+
if (lang === "data")
|
|
17
|
+
return "hash";
|
|
18
|
+
return "hash"; // py + sh
|
|
19
|
+
}
|
|
20
|
+
// Master token pattern over the code part of a line: strings (with
|
|
21
|
+
// escapes, incl. unterminated tails so streaming/odd lines still paint),
|
|
22
|
+
// numbers, words, and single fallback chars.
|
|
23
|
+
const TOKEN_RE = /'(?:[^'\\\n]|\\.)*(?:'|$)|"(?:[^"\\\n]|\\.)*(?:"|$)|`(?:[^`\\]|\\.)*(?:`|$)|[0-9][0-9_]*(?:\.[0-9_]+)?\b|[A-Za-z_$][A-Za-z0-9_$]*|\s+|./g;
|
|
24
|
+
const NUMBER_RE = /^[0-9][0-9_]*(?:\.[0-9_]+)?\b$/;
|
|
25
|
+
const WORD_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
26
|
+
// Split off a trailing line comment, honoring string spans: `//` inside
|
|
27
|
+
// a string is code, and (for hash style) `#` inside a string is code.
|
|
28
|
+
// Single-line `/* … */` pairs are treated as comments when both halves
|
|
29
|
+
// sit on this line; an unterminated opener is left as code (multi-line
|
|
30
|
+
// state is the documented non-goal).
|
|
31
|
+
function splitComment(line, lang) {
|
|
32
|
+
const style = commentStyle(lang);
|
|
33
|
+
let inStr = null;
|
|
34
|
+
let escaped = false;
|
|
35
|
+
for (let i = 0; i < line.length; i++) {
|
|
36
|
+
const c = line[i];
|
|
37
|
+
if (inStr !== null) {
|
|
38
|
+
if (escaped)
|
|
39
|
+
escaped = false;
|
|
40
|
+
else if (c === "\\")
|
|
41
|
+
escaped = true;
|
|
42
|
+
else if (c === inStr)
|
|
43
|
+
inStr = null;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
47
|
+
inStr = c;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (style === "slash" && c === "/" && line[i + 1] === "/") {
|
|
51
|
+
return { code: line.slice(0, i), comment: line.slice(i) };
|
|
52
|
+
}
|
|
53
|
+
if (style === "hash" && c === "#") {
|
|
54
|
+
// Shebang or comment to end of line.
|
|
55
|
+
return { code: line.slice(0, i), comment: line.slice(i) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (style === "slash") {
|
|
59
|
+
const open = line.indexOf("/*");
|
|
60
|
+
const close = open >= 0 ? line.indexOf("*/", open + 2) : -1;
|
|
61
|
+
if (open >= 0 && close > open) {
|
|
62
|
+
// Keep it simple: trailing block comment paints as comment; an
|
|
63
|
+
// embedded one splits code around it via the token pass below
|
|
64
|
+
// (rare — paint the whole tail as comment only when the opener
|
|
65
|
+
// starts after code we already keep plain).
|
|
66
|
+
return { code: line.slice(0, open), comment: line.slice(open) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { code: line, comment: "" };
|
|
70
|
+
}
|
|
71
|
+
function highlightCode(code, lang, base) {
|
|
72
|
+
const runs = [];
|
|
73
|
+
const keywords = keywordsFor(lang);
|
|
74
|
+
TOKEN_RE.lastIndex = 0;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = TOKEN_RE.exec(code)) !== null) {
|
|
77
|
+
const text = m[0];
|
|
78
|
+
const start = base + (m.index ?? 0);
|
|
79
|
+
let kind = "plain";
|
|
80
|
+
const first = text[0];
|
|
81
|
+
if (first === "'" || first === '"' || first === "`")
|
|
82
|
+
kind = "string";
|
|
83
|
+
else if (NUMBER_RE.test(text))
|
|
84
|
+
kind = "number";
|
|
85
|
+
else if (keywords !== null && WORD_RE.test(text) && keywords.has(text))
|
|
86
|
+
kind = "keyword";
|
|
87
|
+
runs.push({ text, kind, start, end: start + text.length });
|
|
88
|
+
}
|
|
89
|
+
return runs;
|
|
90
|
+
}
|
|
91
|
+
// Bounded highlight cache: diff hunks re-render on busy ticks and the
|
|
92
|
+
// same lines repeat across hunks/turns — tokenize once per unique line.
|
|
93
|
+
const HIGHLIGHT_CACHE_CAP = 2000;
|
|
94
|
+
const highlightCache = new Map();
|
|
95
|
+
export function highlightLine(line, lang) {
|
|
96
|
+
if (lang !== "c" && lang !== "py" && lang !== "sh" && lang !== "data") {
|
|
97
|
+
return line === "" ? [] : [{ text: line, kind: "plain", start: 0, end: line.length }];
|
|
98
|
+
}
|
|
99
|
+
const key = `${lang} ${line}`;
|
|
100
|
+
const hit = highlightCache.get(key);
|
|
101
|
+
if (hit)
|
|
102
|
+
return hit;
|
|
103
|
+
const { code, comment } = splitComment(line, lang);
|
|
104
|
+
const runs = highlightCode(code, lang, 0);
|
|
105
|
+
if (comment) {
|
|
106
|
+
runs.push({ text: comment, kind: "comment", start: code.length, end: line.length });
|
|
107
|
+
}
|
|
108
|
+
const out = runs.length > 0 ? runs : [];
|
|
109
|
+
highlightCache.set(key, out);
|
|
110
|
+
if (highlightCache.size > HIGHLIGHT_CACHE_CAP) {
|
|
111
|
+
const oldest = highlightCache.keys().next();
|
|
112
|
+
if (!oldest.done)
|
|
113
|
+
highlightCache.delete(oldest.value);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
// Test seam: current cache size (eviction behavior).
|
|
118
|
+
export function highlightCacheSize() {
|
|
119
|
+
return highlightCache.size;
|
|
120
|
+
}
|
|
@@ -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 }) {
|
|
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 ? (_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/modals.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Modal leaves: the tool-approval and ask_question dialogs. Prop-driven.
|
|
3
|
+
// All paint comes from ui/theme tokens. The approval tool-call description
|
|
4
|
+
// arrives pre-formatted (describeToolCall stays in App) so this module
|
|
5
|
+
// couples to no tool internals — the approval-redesign chunk owns it.
|
|
6
|
+
import React from "react";
|
|
2
7
|
import { Box, Text } from "ink";
|
|
8
|
+
import { SideBySideDiffView } from "./side-by-side.js";
|
|
3
9
|
import { theme } from "./theme.js";
|
|
10
|
+
// Max diff body lines inside the approval modal (hunk headers excluded;
|
|
11
|
+
// the trailer names the remainder). Keeps the modal scannable while the
|
|
12
|
+
// 1s busy tick repaints around it.
|
|
13
|
+
export const APPROVAL_DIFF_MAX_LINES = 40;
|
|
4
14
|
export const APPROVAL_OPTIONS = ["once", "always", "trustAll", "no"];
|
|
5
15
|
// Command/file preview: the audit description minus its `⚙ name` prefix
|
|
6
16
|
// (the tool name already headlines above). Falls back to the full text
|
|
@@ -14,7 +24,13 @@ export function approvalPreview(toolName, description) {
|
|
|
14
24
|
export function approvalTitle(toolName) {
|
|
15
25
|
return toolName.length > 0 ? toolName[0].toUpperCase() + toolName.slice(1) : toolName;
|
|
16
26
|
}
|
|
17
|
-
|
|
27
|
+
// Render-count probes for the flicker tests: the 1s busy tick and unrelated
|
|
28
|
+
// parent churn must skip both modals (only changed props repaint — nav
|
|
29
|
+
// selection still paints exactly once per keypress).
|
|
30
|
+
export const approvalRenderProbe = { count: 0 };
|
|
31
|
+
export const questionRenderProbe = { count: 0 };
|
|
32
|
+
export const ApprovalBox = React.memo(function ApprovalBox({ toolName, description, selected, diff }) {
|
|
33
|
+
approvalRenderProbe.count += 1;
|
|
18
34
|
const rows = [
|
|
19
35
|
// Labels keep the historical [y]/[a]/[t]/[n] shortcuts (pinned by tests
|
|
20
36
|
// + muscle memory): arrows are additive, shortcuts never move.
|
|
@@ -23,8 +39,9 @@ export function ApprovalBox({ toolName, description, selected }) {
|
|
|
23
39
|
{ label: "[t]rust all write/edit/bash this session", option: "trustAll" },
|
|
24
40
|
{ label: "[n]o — deny this call", option: "no" },
|
|
25
41
|
];
|
|
26
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.approval, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, color: theme.color.warning, children: [theme.symbol.warningMark, " Atom permission \u2014 allow this tool?"] }), _jsx(Text, { bold: true, children: approvalTitle(toolName) }), _jsx(Text, { color: theme.color.code, children: approvalPreview(toolName, description) }), rows.map((r, i) => (_jsxs(Text, { color: i === selected ? theme.color.selection : undefined, children: [i === selected ? `${theme.symbol.select} ` : theme.spacing.rowIndent, r.label] }, r.option))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter selects \u00B7 y/a/t/n shortcuts \u00B7 Esc denies" })] }));
|
|
27
|
-
}
|
|
28
|
-
export function QuestionBox({ question, options, allowCustom, askCustom, askSelIndex }) {
|
|
42
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.approval, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, color: theme.color.warning, children: [theme.symbol.warningMark, " Atom permission \u2014 allow this tool?"] }), _jsx(Text, { bold: true, children: approvalTitle(toolName) }), _jsx(Text, { color: theme.color.code, children: approvalPreview(toolName, description) }), diff ? _jsx(SideBySideDiffView, { oldText: diff.oldText, newText: diff.newText, lang: diff.lang, maxRows: APPROVAL_DIFF_MAX_LINES }) : null, rows.map((r, i) => (_jsxs(Text, { color: i === selected ? theme.color.selection : undefined, children: [i === selected ? `${theme.symbol.select} ` : theme.spacing.rowIndent, r.label] }, r.option))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter selects \u00B7 y/a/t/n shortcuts \u00B7 Esc denies" })] }));
|
|
43
|
+
});
|
|
44
|
+
export const QuestionBox = React.memo(function QuestionBox({ question, options, allowCustom, askCustom, askSelIndex }) {
|
|
45
|
+
questionRenderProbe.count += 1;
|
|
29
46
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.question, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom question \u2014 ", question] }), options.map((o, i) => (_jsxs(Text, { color: i === askSelIndex ? theme.color.questionSelection : undefined, children: [i === askSelIndex ? `${theme.symbol.select} ` : theme.spacing.rowIndent, o] }, `${o}-${i}`))), allowCustom ? (_jsxs(Text, { dimColor: true, children: ["Type a custom answer + Enter to send it", askCustom ? `: ${askCustom}` : "", " \u00B7 \u2191/\u2193 + Enter picks \u00B7 Esc cancels"] })) : (_jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter to pick \u00B7 Esc cancels" }))] }));
|
|
30
|
-
}
|
|
47
|
+
});
|
package/dist/ui/palette.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Command-palette panel (Ctrl+P): grouped, searchable, keyboard-driven.
|
|
3
|
+
// Owns its display types (categories, hints); App builds the entries from
|
|
4
|
+
// the SLASH_COMMANDS registry (single command system) and owns the run
|
|
5
|
+
// gating. Windowed like every other popup; headers render for groups
|
|
6
|
+
// present in the window (plus the open group when sliced mid-way).
|
|
7
|
+
import React from "react";
|
|
2
8
|
import { Box, Text } from "ink";
|
|
3
9
|
import { PickerMoreAbove, PickerMoreBelow, pickerWindow } from "./pickers.js";
|
|
4
10
|
import { theme } from "./theme.js";
|
|
@@ -12,6 +18,8 @@ const PALETTE_CATEGORIES = {
|
|
|
12
18
|
"/clear": "Session",
|
|
13
19
|
"/new": "Session",
|
|
14
20
|
"/resume": "Session",
|
|
21
|
+
"/rename": "Session",
|
|
22
|
+
"/session": "Session",
|
|
15
23
|
"/rewind": "Session",
|
|
16
24
|
"/context": "Session",
|
|
17
25
|
"/telemetry": "Session",
|
|
@@ -26,6 +34,8 @@ const PALETTE_CATEGORIES = {
|
|
|
26
34
|
"/skill": "Skills",
|
|
27
35
|
"/queue": "Flow",
|
|
28
36
|
"/steer": "Flow",
|
|
37
|
+
"/autoscroll": "Flow",
|
|
38
|
+
"/thinking": "Flow",
|
|
29
39
|
"/help": "Help",
|
|
30
40
|
"/exit": "Help",
|
|
31
41
|
"/quit": "Help",
|
|
@@ -39,7 +49,7 @@ export const PALETTE_HINTS = {
|
|
|
39
49
|
"/exit": "Ctrl+C",
|
|
40
50
|
"/quit": "Ctrl+C",
|
|
41
51
|
};
|
|
42
|
-
export function PalettePanel({ entries, index, filter }) {
|
|
52
|
+
export const PalettePanel = React.memo(function PalettePanel({ entries, index, filter }) {
|
|
43
53
|
const hi = entries.length === 0 ? 0 : Math.max(0, Math.min(index, entries.length - 1));
|
|
44
54
|
const win = pickerWindow(entries.length, hi, PALETTE_WINDOW);
|
|
45
55
|
const slice = entries.slice(win.start, win.end);
|
|
@@ -59,4 +69,4 @@ export function PalettePanel({ entries, index, filter }) {
|
|
|
59
69
|
rows.push(_jsxs(Text, { color: i === hi ? theme.color.menuSelection : undefined, children: [i === hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, e.name, e.description ? ` ${theme.symbol.descSeparator} ${e.description}` : "", e.hint ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", e.hint] }) : null] }, `${e.name}-${i}`));
|
|
60
70
|
});
|
|
61
71
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.menu, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Search commands \u2014 type to filter (\u2191/\u2193 + Enter to run, Esc closes):" }), _jsxs(Text, { children: [_jsxs(Text, { color: theme.color.inputPrompt, bold: true, children: [theme.symbol.inputPrompt, " "] }), filter, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), _jsx(PickerMoreAbove, { count: win.start }), rows, _jsx(PickerMoreBelow, { count: entries.length - win.end }), entries.length === 0 ? _jsx(Text, { dimColor: true, children: "No commands match \u2014 backspace to widen." }) : null] }));
|
|
62
|
-
}
|
|
72
|
+
});
|
|
@@ -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 (local useStdout, same
|
|
13
|
+
// pattern as StatusBarHost); 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);
|
package/dist/ui/status-bar.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
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";
|
|
2
12
|
import { Box, Text } from "ink";
|
|
3
13
|
import { formatTokenSegment } from "../context-windows.js";
|
|
4
14
|
import { theme } from "./theme.js";
|
|
@@ -20,7 +30,12 @@ export function shrinkTo(s, n) {
|
|
|
20
30
|
return "";
|
|
21
31
|
return `…/${s.slice(-(n - 3))}`;
|
|
22
32
|
}
|
|
23
|
-
|
|
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;
|
|
24
39
|
const bar = theme.symbol.bar;
|
|
25
40
|
if (!busy) {
|
|
26
41
|
const token = formatTokenSegment(usageTotals, model, contextLoad);
|
|
@@ -49,11 +64,12 @@ export function StatusBar({ provider, model, usageTotals, contextLoad, reasoning
|
|
|
49
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] }) }));
|
|
50
65
|
}
|
|
51
66
|
// Busy layout prioritizes activity + clock + interrupt hint; the mode
|
|
52
|
-
// stays pinned (it used to vanish while working)
|
|
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
|
|
53
69
|
// shrinks to fit so `esc stops` never wraps away.
|
|
54
70
|
const busyTrust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
55
|
-
const busyFixed = ` ${bar} ${elapsedSecs}s ${bar} ${formatTokenSegment(usageTotals, model, contextLoad)} ${bar} mode: ${mode}${busyTrust} ${bar} esc stops`;
|
|
71
|
+
const busyFixed = ` ${bar} ${elapsedSecs}s ${bar} ${formatTokenSegment(usageTotals, model, contextLoad)} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust} ${bar} esc stops`;
|
|
56
72
|
const busyAvail = columns - busyFixed.length - 2;
|
|
57
73
|
const activityText = shrinkTo(activity ?? phaseLabel, Math.max(0, busyAvail));
|
|
58
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] }) }));
|
|
59
|
-
}
|
|
75
|
+
});
|
|
@@ -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
|
+
}
|
package/dist/ui/theme.js
CHANGED
|
@@ -54,6 +54,12 @@ export const theme = {
|
|
|
54
54
|
code: "green",
|
|
55
55
|
link: "cyan",
|
|
56
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",
|
|
57
63
|
},
|
|
58
64
|
border: {
|
|
59
65
|
style: "round",
|
package/dist/ui/todo-panel.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
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";
|
|
2
6
|
import { Box, Text } from "ink";
|
|
3
7
|
import { theme } from "./theme.js";
|
|
4
8
|
// Live session checklist (Claude-Code-style TodoWrite panel). Mounted in
|
|
@@ -6,7 +10,11 @@ import { theme } from "./theme.js";
|
|
|
6
10
|
// by a snapshot the loop refreshes after every todowrite/todo_update call,
|
|
7
11
|
// so the in-progress row — shown with its activeForm when present — always
|
|
8
12
|
// answers "what is the model doing right now". Returns null when empty.
|
|
9
|
-
|
|
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;
|
|
10
18
|
if (items.length === 0)
|
|
11
19
|
return null;
|
|
12
20
|
const done = items.filter((t) => t.status === "completed").length;
|
|
@@ -19,4 +27,4 @@ export function TodoPanel({ items }) {
|
|
|
19
27
|
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
|
20
28
|
return (_jsxs(Text, { dimColor: t.status === "completed", children: [mark, " ", label, t.priority ? ` (${t.priority})` : ""] }, `${i}-${t.content}`));
|
|
21
29
|
})] }));
|
|
22
|
-
}
|
|
30
|
+
});
|
|
@@ -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,
|