@ocis/myagent-cli 0.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/README.md +357 -0
- package/dist/agent/context.d.ts +33 -0
- package/dist/agent/context.js +169 -0
- package/dist/agent/modes.d.ts +21 -0
- package/dist/agent/modes.js +84 -0
- package/dist/agent/prompt-builder.d.ts +9 -0
- package/dist/agent/prompt-builder.js +31 -0
- package/dist/agent/sessions.d.ts +38 -0
- package/dist/agent/sessions.js +130 -0
- package/dist/agent/todo.d.ts +18 -0
- package/dist/agent/todo.js +61 -0
- package/dist/agent/turn.d.ts +309 -0
- package/dist/agent/turn.js +1253 -0
- package/dist/approval/policy.d.ts +81 -0
- package/dist/approval/policy.js +157 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +156 -0
- package/dist/git/status.d.ts +89 -0
- package/dist/git/status.js +226 -0
- package/dist/headless.d.ts +72 -0
- package/dist/headless.js +330 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +511 -0
- package/dist/protocol/client.d.ts +123 -0
- package/dist/protocol/client.js +250 -0
- package/dist/protocol/sse-frames.d.ts +6 -0
- package/dist/protocol/sse-frames.js +75 -0
- package/dist/protocol/types.d.ts +200 -0
- package/dist/protocol/types.js +8 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +166 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +21 -0
- package/dist/skills/discovery.d.ts +24 -0
- package/dist/skills/discovery.js +109 -0
- package/dist/tools/binary.d.ts +2 -0
- package/dist/tools/binary.js +22 -0
- package/dist/tools/diff.d.ts +1 -0
- package/dist/tools/diff.js +49 -0
- package/dist/tools/find.d.ts +2 -0
- package/dist/tools/find.js +61 -0
- package/dist/tools/fs.d.ts +2 -0
- package/dist/tools/fs.js +276 -0
- package/dist/tools/glob.d.ts +6 -0
- package/dist/tools/glob.js +131 -0
- package/dist/tools/grep.d.ts +3 -0
- package/dist/tools/grep.js +228 -0
- package/dist/tools/paths.d.ts +27 -0
- package/dist/tools/paths.js +124 -0
- package/dist/tools/registry.d.ts +13 -0
- package/dist/tools/registry.js +38 -0
- package/dist/tools/shell.d.ts +2 -0
- package/dist/tools/shell.js +136 -0
- package/dist/tools/skills.d.ts +2 -0
- package/dist/tools/skills.js +36 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +43 -0
- package/dist/tools/transfer.d.ts +2 -0
- package/dist/tools/transfer.js +145 -0
- package/dist/tools/truncate.d.ts +12 -0
- package/dist/tools/truncate.js +46 -0
- package/dist/tools/types.d.ts +85 -0
- package/dist/tools/types.js +63 -0
- package/dist/ui/app.d.ts +39 -0
- package/dist/ui/app.js +1061 -0
- package/dist/ui/colors.d.ts +100 -0
- package/dist/ui/colors.js +169 -0
- package/dist/ui/components.d.ts +267 -0
- package/dist/ui/components.js +811 -0
- package/dist/ui/diff.d.ts +37 -0
- package/dist/ui/diff.js +143 -0
- package/dist/ui/format.d.ts +28 -0
- package/dist/ui/format.js +76 -0
- package/dist/ui/help.d.ts +6 -0
- package/dist/ui/help.js +45 -0
- package/dist/ui/highlight.d.ts +20 -0
- package/dist/ui/highlight.js +210 -0
- package/dist/ui/logo.d.ts +24 -0
- package/dist/ui/logo.js +106 -0
- package/dist/ui/model-list.d.ts +10 -0
- package/dist/ui/model-list.js +33 -0
- package/dist/ui/quit-confirm.d.ts +8 -0
- package/dist/ui/quit-confirm.js +40 -0
- package/dist/ui/select-popup.d.ts +30 -0
- package/dist/ui/select-popup.js +54 -0
- package/dist/ui/theme.d.ts +4 -0
- package/dist/ui/theme.js +41 -0
- package/dist/ui/tool-view.d.ts +20 -0
- package/dist/ui/tool-view.js +326 -0
- package/package.json +44 -0
- package/skills/git-commit/SKILL.md +27 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
// -------------------------------------------------------------------
|
|
2
|
+
// Transcript + panel components for the CLI TUI.
|
|
3
|
+
//
|
|
4
|
+
// Every custom component follows pi-tui's contract: `render(width)` returns
|
|
5
|
+
// lines that never exceed `width`, and `invalidate()` drops caches.
|
|
6
|
+
// -------------------------------------------------------------------
|
|
7
|
+
import { Editor, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import { MODES } from "../agent/modes.js";
|
|
9
|
+
import { AUTO_COLOR, GRAY_COLOR, GRAY_DIM_COLOR, SESSION_TITLE_COLOR, TITLE_COLOR, assistantColor, bgRgb, diffAdd, diffDel, paintLine, sectionFg, style, userMessageAccent, userMessageColor } from "./colors.js";
|
|
10
|
+
import { fit, formatCacheHit, formatContextNumbers, formatContextPercent, formatCost, formatTokens, stripAnsi, todoMark } from "./format.js";
|
|
11
|
+
import { diffNumber, diffNumberWidth, parseDiffLines, renderPairRows } from "./diff.js";
|
|
12
|
+
import { sanitizeForTerminal } from "../sanitize.js";
|
|
13
|
+
import { markdownTheme } from "./theme.js";
|
|
14
|
+
import { displayToolName, toolBody, toolSummary } from "./tool-view.js";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Transcript entries
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Messages submitted while a run is active, floating between the transcript
|
|
20
|
+
* and the prompt editor. A steered message is NOT part of the conversation
|
|
21
|
+
* until the server takes it — until then it stays here, one dim preview line
|
|
22
|
+
* per pending message, and is released (into the transcript) on acceptance
|
|
23
|
+
* or when a re-queued follow-up turn actually starts.
|
|
24
|
+
*/
|
|
25
|
+
export class QueuedMessagesComponent {
|
|
26
|
+
entries = [];
|
|
27
|
+
invalidate() { }
|
|
28
|
+
add(text) {
|
|
29
|
+
this.entries.push(text);
|
|
30
|
+
}
|
|
31
|
+
/** Release the oldest entry with exactly this text (FIFO — steering the
|
|
32
|
+
* same text twice releases one float per acceptance). */
|
|
33
|
+
release(text) {
|
|
34
|
+
const index = this.entries.indexOf(text);
|
|
35
|
+
if (index >= 0)
|
|
36
|
+
this.entries.splice(index, 1);
|
|
37
|
+
}
|
|
38
|
+
render(width) {
|
|
39
|
+
// Inset to align with the prompt box (Box(2, 0)); one compact line per
|
|
40
|
+
// pending message — the full text renders once it enters the transcript.
|
|
41
|
+
const inset = 2;
|
|
42
|
+
const prefix = "⏳ ";
|
|
43
|
+
const budget = Math.max(4, width - inset - visibleWidth(prefix));
|
|
44
|
+
return this.entries.map((text) => {
|
|
45
|
+
const firstLine = text.split("\n")[0] ?? "";
|
|
46
|
+
const more = text.includes("\n") ? " …" : "";
|
|
47
|
+
const preview = truncateToWidth(firstLine + more, budget, "…", true);
|
|
48
|
+
return `${" ".repeat(inset)}${GRAY_DIM_COLOR(style.italic(`${prefix}${preview}`))}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A submitted prompt: a leading blank row (margin from the previous message),
|
|
54
|
+
* a violet block bar down the left edge, and a content-sized tinted bubble
|
|
55
|
+
* (with one row of vertical padding) when the terminal background is known.
|
|
56
|
+
*/
|
|
57
|
+
export class UserMessageComponent {
|
|
58
|
+
text;
|
|
59
|
+
queued;
|
|
60
|
+
options;
|
|
61
|
+
constructor(text, queued, options = {}) {
|
|
62
|
+
this.text = text;
|
|
63
|
+
this.queued = queued;
|
|
64
|
+
this.options = options;
|
|
65
|
+
}
|
|
66
|
+
invalidate() { }
|
|
67
|
+
render(width) {
|
|
68
|
+
const bg = this.options.bg?.();
|
|
69
|
+
const indent = " ";
|
|
70
|
+
const contentWidth = Math.max(1, width - indent.length - 2);
|
|
71
|
+
const lines = wrapTextWithAnsi(this.text.trim() || "(empty)", contentWidth);
|
|
72
|
+
const suffix = this.queued ? style.dim(" (queued)") : "";
|
|
73
|
+
// Every line carries the block, so the marker reads as one bar down the
|
|
74
|
+
// whole bubble instead of a one-line accent.
|
|
75
|
+
const rendered = lines.map((line, i) => `${userMessageAccent("▌ ")}${userMessageColor(line)}${i === 0 ? suffix : ""}`);
|
|
76
|
+
// The bubble hugs its content: marker + widest line + a little right padding.
|
|
77
|
+
const bandWidth = Math.max(1, Math.min(width - indent.length, rendered.reduce((widest, line) => Math.max(widest, visibleWidth(line)), 0) + 2));
|
|
78
|
+
const paint = (text) => `${indent}${paintLine(text, bandWidth, bg)}`;
|
|
79
|
+
return [
|
|
80
|
+
"",
|
|
81
|
+
paint(""),
|
|
82
|
+
...rendered.map(paint),
|
|
83
|
+
paint(""),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class AssistantMessageComponent {
|
|
88
|
+
md;
|
|
89
|
+
/** Raw stream (unsanitized) — the sanitizing pass needs the whole text. */
|
|
90
|
+
raw = "";
|
|
91
|
+
text = "";
|
|
92
|
+
constructor() {
|
|
93
|
+
this.md = new Markdown("", 0, 0, markdownTheme, { color: assistantColor });
|
|
94
|
+
}
|
|
95
|
+
append(delta) {
|
|
96
|
+
// Model text lands on the user's real terminal and can quote untrusted
|
|
97
|
+
// material (file bodies, fetched pages) verbatim — strip terminal
|
|
98
|
+
// sequences, like tool output and headless deltas do. Sanitizing the
|
|
99
|
+
// ACCUMULATED text, not the delta: a sequence split across two deltas
|
|
100
|
+
// would slip through a per-delta pass.
|
|
101
|
+
this.raw += delta;
|
|
102
|
+
this.text = sanitizeForTerminal(this.raw);
|
|
103
|
+
this.md.setText(this.text);
|
|
104
|
+
}
|
|
105
|
+
get content() {
|
|
106
|
+
return this.text;
|
|
107
|
+
}
|
|
108
|
+
render(width) {
|
|
109
|
+
// Render at the widest margin any line gets (list/table lines take 4):
|
|
110
|
+
// pi-tui pads every line to this width, so a narrower inner would push
|
|
111
|
+
// those lines past `width` and fit() would truncate them ("..." on
|
|
112
|
+
// blank lines). Prose/code lines just carry a little less right padding.
|
|
113
|
+
const inner = Math.max(1, width - 4);
|
|
114
|
+
const lines = [];
|
|
115
|
+
let table = false;
|
|
116
|
+
let inCode = false;
|
|
117
|
+
let inList = false;
|
|
118
|
+
for (const line of this.md.render(inner)) {
|
|
119
|
+
const plain = stripAnsi(line);
|
|
120
|
+
// Fenced code otherwise renders with literal ``` fence lines — the
|
|
121
|
+
// raw-markdown tell. Drop them; the codeBlockIndent plus the syntax
|
|
122
|
+
// highlighting (theme.highlightCode) carry the block.
|
|
123
|
+
if (plain.startsWith("```")) {
|
|
124
|
+
inCode = !inCode;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Code blocks indent first (col 4, level with the list blocks), then
|
|
128
|
+
// the dim bar marks the block: pi-tui's 2-cell codeBlockIndent
|
|
129
|
+
// (pinned in the theme) is swapped out so the code lands at col 6.
|
|
130
|
+
// Code lines must not join the list logic below.
|
|
131
|
+
if (inCode) {
|
|
132
|
+
lines.push(fit(` ${style.dim("▎ ")}${line.slice(2)}`, width));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// List blocks — items, nested items, wrapped continuations — indent one
|
|
136
|
+
// level deeper than prose so they read as a group. A blank line does
|
|
137
|
+
// not end the list (items are separated by blanks); any other
|
|
138
|
+
// non-indented line does.
|
|
139
|
+
if (/^\s*(• |\d+[.)] )/.test(plain))
|
|
140
|
+
inList = true;
|
|
141
|
+
else if (plain.trim() && !/^\s/.test(plain))
|
|
142
|
+
inList = false;
|
|
143
|
+
// h3-h6 render with their literal "### " prefix — strip it; the
|
|
144
|
+
// heading style (h1 additionally underlined) carries the level.
|
|
145
|
+
const headingPrefix = /^#{3,6} /.exec(plain)?.[0];
|
|
146
|
+
const content = headingPrefix ? line.replace(headingPrefix, "") : line;
|
|
147
|
+
const startsTable = content.startsWith("┌");
|
|
148
|
+
const isTable = table || startsTable;
|
|
149
|
+
if (startsTable)
|
|
150
|
+
table = true;
|
|
151
|
+
if (content.startsWith("└"))
|
|
152
|
+
table = false;
|
|
153
|
+
// Table lines are re-indented: drop their trailing padding first so
|
|
154
|
+
// fit() cannot clip the right border.
|
|
155
|
+
const margin = isTable || inList ? " " : " ";
|
|
156
|
+
lines.push(fit(`${margin}${isTable ? content.trimEnd() : content}`, width));
|
|
157
|
+
}
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
invalidate() {
|
|
161
|
+
this.md.invalidate();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export class ReasoningBlockComponent {
|
|
165
|
+
raw = "";
|
|
166
|
+
text = "";
|
|
167
|
+
expanded = false;
|
|
168
|
+
append(delta) {
|
|
169
|
+
// Same rule as assistant text: reasoning is model output rendered to the
|
|
170
|
+
// real terminal, and it quotes tool output freely.
|
|
171
|
+
this.raw += delta;
|
|
172
|
+
this.text = sanitizeForTerminal(this.raw);
|
|
173
|
+
}
|
|
174
|
+
toggle() {
|
|
175
|
+
this.expanded = !this.expanded;
|
|
176
|
+
}
|
|
177
|
+
get hasContent() {
|
|
178
|
+
return this.text.trim().length > 0;
|
|
179
|
+
}
|
|
180
|
+
invalidate() { }
|
|
181
|
+
render(width) {
|
|
182
|
+
if (!this.hasContent)
|
|
183
|
+
return [];
|
|
184
|
+
if (!this.expanded) {
|
|
185
|
+
const chars = this.text.trim().length;
|
|
186
|
+
return [truncateToWidth(style.dim(` ▸ thinking (${chars} chars · Ctrl+T)`), width)];
|
|
187
|
+
}
|
|
188
|
+
const body = wrapTextWithAnsi(style.dim(this.text.trim()), Math.max(1, width - 4));
|
|
189
|
+
return [
|
|
190
|
+
truncateToWidth(style.dim(" ▾ thinking"), width),
|
|
191
|
+
...body.map((line) => truncateToWidth(` ${line}`, width)),
|
|
192
|
+
"",
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export class ToolCallComponent {
|
|
197
|
+
name;
|
|
198
|
+
args;
|
|
199
|
+
status = "running";
|
|
200
|
+
expanded = false;
|
|
201
|
+
output = "";
|
|
202
|
+
bodyCache;
|
|
203
|
+
constructor(name, args) {
|
|
204
|
+
this.name = name;
|
|
205
|
+
this.args = args;
|
|
206
|
+
}
|
|
207
|
+
setResult(output, isError) {
|
|
208
|
+
this.output = output;
|
|
209
|
+
this.status = isError ? "error" : "done";
|
|
210
|
+
}
|
|
211
|
+
toggle() {
|
|
212
|
+
this.expanded = !this.expanded;
|
|
213
|
+
}
|
|
214
|
+
/** The whole row is the button: press is consumed, the release click toggles. */
|
|
215
|
+
handleMouse(event) {
|
|
216
|
+
if (event.type !== "press" && event.type !== "click")
|
|
217
|
+
return undefined;
|
|
218
|
+
if (event.type === "press")
|
|
219
|
+
return { render: false };
|
|
220
|
+
this.toggle();
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
invalidate() { }
|
|
224
|
+
/** Tool-specific expanded body, cached per width/result (renders on spinner ticks). */
|
|
225
|
+
body(width) {
|
|
226
|
+
const isError = this.status === "error";
|
|
227
|
+
const cached = this.bodyCache;
|
|
228
|
+
if (cached && cached.width === width && cached.output === this.output && cached.isError === isError) {
|
|
229
|
+
return cached.lines;
|
|
230
|
+
}
|
|
231
|
+
const lines = toolBody(this.name, this.args, this.output, isError, width);
|
|
232
|
+
this.bodyCache = { width, output: this.output, isError, lines };
|
|
233
|
+
return lines;
|
|
234
|
+
}
|
|
235
|
+
render(width) {
|
|
236
|
+
const glyph = this.status === "running" ? style.yellow("●") : this.status === "error" ? style.red("✗") : style.green("✓");
|
|
237
|
+
const marker = style.dim(this.expanded ? "▾" : "▸");
|
|
238
|
+
const summary = toolSummary(this.name, this.args);
|
|
239
|
+
const header = ` ${marker} ${glyph} ${style.bold(TITLE_COLOR(displayToolName(this.name)))}${summary ? ` ${style.dim(summary)}` : ""}`;
|
|
240
|
+
const lines = [truncateToWidth(header, width)];
|
|
241
|
+
if (this.expanded) {
|
|
242
|
+
const bodyWidth = Math.max(8, width - 6);
|
|
243
|
+
for (const line of this.body(bodyWidth))
|
|
244
|
+
lines.push(truncateToWidth(` ${line}`, width));
|
|
245
|
+
}
|
|
246
|
+
lines.push("");
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export class NoticeComponent {
|
|
251
|
+
text;
|
|
252
|
+
level;
|
|
253
|
+
constructor(text, level) {
|
|
254
|
+
this.text = text;
|
|
255
|
+
this.level = level;
|
|
256
|
+
}
|
|
257
|
+
invalidate() { }
|
|
258
|
+
render(width) {
|
|
259
|
+
const color = this.level === "error" ? style.red : this.level === "warn" ? style.yellow : style.dim;
|
|
260
|
+
// Notices carry server-driven text and error messages — untrusted.
|
|
261
|
+
return [truncateToWidth(color(` ${sanitizeForTerminal(this.text)}`), width), ""];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// TODO panel
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
/** Checkbox glyph colored by status (sidebar TODO section). */
|
|
268
|
+
function todoGlyph(status) {
|
|
269
|
+
const mark = todoMark(status);
|
|
270
|
+
return status === "completed" ? style.green(mark) : status === "in_progress" ? style.yellow(mark) : style.dim(mark);
|
|
271
|
+
}
|
|
272
|
+
const COLLAPSED_FILES = 10;
|
|
273
|
+
export class SidebarComponent {
|
|
274
|
+
state;
|
|
275
|
+
opts;
|
|
276
|
+
expanded = false;
|
|
277
|
+
targets = new Map();
|
|
278
|
+
constructor(state, opts) {
|
|
279
|
+
this.state = state;
|
|
280
|
+
this.opts = opts;
|
|
281
|
+
}
|
|
282
|
+
invalidate() { }
|
|
283
|
+
toggleChanges() {
|
|
284
|
+
this.expanded = !this.expanded;
|
|
285
|
+
}
|
|
286
|
+
/** What a click at content line `y` hits (coordinates include scrollTop). */
|
|
287
|
+
targetAt(y) {
|
|
288
|
+
return this.targets.get(y);
|
|
289
|
+
}
|
|
290
|
+
render(width) {
|
|
291
|
+
const w = Math.max(8, width);
|
|
292
|
+
const panel = this.opts.panelBg?.();
|
|
293
|
+
const fill = (line) => {
|
|
294
|
+
const padded = truncateToWidth(line, w, "…", true);
|
|
295
|
+
return panel ? bgRgb(panel, padded) : padded;
|
|
296
|
+
};
|
|
297
|
+
const lines = [];
|
|
298
|
+
const push = (line) => lines.push(fill(line));
|
|
299
|
+
const blank = () => push("");
|
|
300
|
+
const track = (target) => {
|
|
301
|
+
this.targets.set(lines.length - 1, target);
|
|
302
|
+
};
|
|
303
|
+
// Section header: a hue-colored accent bar + the soft light gray title.
|
|
304
|
+
const section = (name, label) => {
|
|
305
|
+
push(` ${sectionFg(name, panel)("▌")}${style.bold(TITLE_COLOR(label))}`);
|
|
306
|
+
};
|
|
307
|
+
// Section content is indented under its header.
|
|
308
|
+
const value = (line) => push(` ${line}`);
|
|
309
|
+
this.targets.clear();
|
|
310
|
+
// Breathing room: a blank line above/below the title and after every
|
|
311
|
+
// section (the panel background makes the gaps read as intentional).
|
|
312
|
+
blank();
|
|
313
|
+
// The session title is the panel's headline — brighter than the section
|
|
314
|
+
// headers below it. The "New session" placeholder stays at section-header
|
|
315
|
+
// level so it doesn't read as a real title.
|
|
316
|
+
push(this.state.title
|
|
317
|
+
? ` ${style.bold(SESSION_TITLE_COLOR(this.state.title))}`
|
|
318
|
+
: ` ${style.bold(TITLE_COLOR("New session"))}`);
|
|
319
|
+
blank();
|
|
320
|
+
const usage = this.state.usage;
|
|
321
|
+
if (usage) {
|
|
322
|
+
section("context", "CONTEXT");
|
|
323
|
+
const percent = formatContextPercent(usage.contextTokens, usage.contextWindow);
|
|
324
|
+
value(GRAY_COLOR(`${formatContextNumbers(usage.contextTokens, usage.contextWindow)}${percent === "—" ? "" : ` (${percent} used)`}`));
|
|
325
|
+
const cost = formatCost(usage);
|
|
326
|
+
const costText = cost ? style.yellow(`~${cost}`) : style.dim("—");
|
|
327
|
+
value(`${costText} ${GRAY_DIM_COLOR(`(${formatCacheHit(usage)} cached)`)}`);
|
|
328
|
+
blank();
|
|
329
|
+
}
|
|
330
|
+
const todoItems = this.opts.todos.list();
|
|
331
|
+
if (todoItems.length > 0) {
|
|
332
|
+
section("todo", `TODO (${this.opts.todos.doneCount}/${todoItems.length})`);
|
|
333
|
+
for (const item of todoItems) {
|
|
334
|
+
const content = item.content.replace(/\s+/g, " ");
|
|
335
|
+
value(`${todoGlyph(item.status)} ${item.status === "completed" ? GRAY_DIM_COLOR(content) : GRAY_COLOR(content)}`);
|
|
336
|
+
}
|
|
337
|
+
blank();
|
|
338
|
+
}
|
|
339
|
+
if (this.state.git.isRepo) {
|
|
340
|
+
const files = this.state.git.files;
|
|
341
|
+
section("changes", `CHANGES (${files.length})`);
|
|
342
|
+
if (files.length === 0) {
|
|
343
|
+
value(GRAY_DIM_COLOR("clean"));
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const shown = this.expanded ? files : files.slice(0, COLLAPSED_FILES);
|
|
347
|
+
for (const file of shown) {
|
|
348
|
+
const counts = file.insertions === 0 && file.deletions === 0 ? "" :
|
|
349
|
+
` ${diffAdd(`+${file.insertions}`)} ${diffDel(`-${file.deletions}`)}`;
|
|
350
|
+
const label = GRAY_COLOR(truncateToWidth(`${file.status} ${file.path}`, Math.max(4, w - 14)));
|
|
351
|
+
push(` ${label}${counts}`);
|
|
352
|
+
track({ type: "file", file });
|
|
353
|
+
}
|
|
354
|
+
if (files.length > shown.length) {
|
|
355
|
+
value(`${style.cyan("…")} ${GRAY_COLOR(`${files.length - shown.length} more`)} ${GRAY_DIM_COLOR("(click to expand)")}`);
|
|
356
|
+
track({ type: "toggle-changes" });
|
|
357
|
+
}
|
|
358
|
+
else if (this.expanded && files.length > COLLAPSED_FILES) {
|
|
359
|
+
value(`${style.cyan("−")} ${GRAY_COLOR("collapse")}`);
|
|
360
|
+
track({ type: "toggle-changes" });
|
|
361
|
+
}
|
|
362
|
+
if (this.state.git.totalInsertions || this.state.git.totalDeletions) {
|
|
363
|
+
value(`${diffAdd(`+${this.state.git.totalInsertions}`)} ${diffDel(`-${this.state.git.totalDeletions}`)} ${GRAY_COLOR("total")}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
blank();
|
|
367
|
+
}
|
|
368
|
+
// Fill the empty space below the content so the panel background covers
|
|
369
|
+
// the whole column. Only when content is shorter than the viewport.
|
|
370
|
+
const viewport = this.opts.viewportHeight?.() ?? 0;
|
|
371
|
+
const filler = panel ? bgRgb(panel, " ".repeat(w)) : " ".repeat(w);
|
|
372
|
+
while (lines.length < viewport)
|
|
373
|
+
lines.push(filler);
|
|
374
|
+
return lines;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export class PromptEditor extends Editor {
|
|
378
|
+
mode;
|
|
379
|
+
model = null;
|
|
380
|
+
thinking;
|
|
381
|
+
/** Fired when a bottom-edge chip (mode / model / thinking) is clicked. */
|
|
382
|
+
onChipClick;
|
|
383
|
+
/** Chip column extents from the last bottom-edge render, for hit-testing. */
|
|
384
|
+
chipRanges = [];
|
|
385
|
+
/** Bottom-edge row in this component's coordinates (-1 = no bottom edge). */
|
|
386
|
+
bottomBorderRow = -1;
|
|
387
|
+
/**
|
|
388
|
+
* The exact bottom-edge line rendered this frame. render() locates the
|
|
389
|
+
* edge by identity against it — scanning for a "╰" substring would misfire
|
|
390
|
+
* on user content that happens to contain the border glyph.
|
|
391
|
+
*/
|
|
392
|
+
lastBottomBorder = "";
|
|
393
|
+
constructor(tui, theme, mode, thinking) {
|
|
394
|
+
super(tui, theme, { paddingX: 2 });
|
|
395
|
+
this.mode = mode;
|
|
396
|
+
this.thinking = thinking;
|
|
397
|
+
}
|
|
398
|
+
renderTopBorder(width, _hiddenLineCount) {
|
|
399
|
+
return this.borderColor(`╭${"─".repeat(Math.max(0, width))}╮`);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Alt+Enter inserts a newline exactly like Shift+Enter — the base editor
|
|
403
|
+
* only knows Shift+Enter/Ctrl+J, but terminals send Alt+Enter as ESC+CR
|
|
404
|
+
* (legacy) or a Kitty/modifyOtherKeys sequence.
|
|
405
|
+
*/
|
|
406
|
+
handleInput(data) {
|
|
407
|
+
if (matchesKey(data, "alt+enter")) {
|
|
408
|
+
this.insertTextAtCursor("\n");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
super.handleInput(data);
|
|
412
|
+
}
|
|
413
|
+
renderBottomBorder(width, hiddenLineCount) {
|
|
414
|
+
const label = ` ${MODES[this.mode].label} `;
|
|
415
|
+
const modelText = this.model ?? "no model";
|
|
416
|
+
const thinkingText = this.thinking ?? "default";
|
|
417
|
+
const info = `· ${modelText} · ${thinkingText} `;
|
|
418
|
+
const suffix = hiddenLineCount > 0 ? `↓ ${hiddenLineCount} more ` : "";
|
|
419
|
+
const fill = width - visibleWidth(label) - visibleWidth(info) - visibleWidth(suffix) - 1;
|
|
420
|
+
if (fill < 0) {
|
|
421
|
+
this.chipRanges = [];
|
|
422
|
+
this.lastBottomBorder = this.borderColor(`╰${"─".repeat(Math.max(0, width))}╯`);
|
|
423
|
+
return this.lastBottomBorder;
|
|
424
|
+
}
|
|
425
|
+
// Chip extents for mouse hit-testing, in columns of the rendered edge
|
|
426
|
+
// line (which spans the full component width, ╰ included).
|
|
427
|
+
const labelStart = 2; // "╰─"
|
|
428
|
+
const infoStart = labelStart + visibleWidth(label);
|
|
429
|
+
const modelStart = infoStart + 2; // "· "
|
|
430
|
+
const thinkingStart = modelStart + visibleWidth(modelText) + 3; // " · "
|
|
431
|
+
this.chipRanges = [
|
|
432
|
+
{ chip: "mode", start: labelStart, end: infoStart },
|
|
433
|
+
{ chip: "model", start: modelStart, end: modelStart + visibleWidth(modelText) },
|
|
434
|
+
{ chip: "thinking", start: thinkingStart, end: thinkingStart + visibleWidth(thinkingText) },
|
|
435
|
+
];
|
|
436
|
+
// The gray info segment resets the foreground, so re-open the frame color
|
|
437
|
+
// for the trailing rule.
|
|
438
|
+
this.lastBottomBorder = this.borderColor(`╰─${style.bold(label)}`) + GRAY_COLOR(info)
|
|
439
|
+
+ this.borderColor(`${"─".repeat(fill)}${suffix}╯`);
|
|
440
|
+
return this.lastBottomBorder;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Mouse dispatch for the framed editor. The bottom edge carries the
|
|
444
|
+
* mode/model/thinking chips; every other event is translated into the base
|
|
445
|
+
* editor's coordinate system — our frame inserts a padding row below the
|
|
446
|
+
* top edge and above the bottom edge, and the side borders shift text rows
|
|
447
|
+
* one column right, so without the shift the base's cursor/autocomplete
|
|
448
|
+
* hit-testing lands one row and one column off.
|
|
449
|
+
*/
|
|
450
|
+
handleMouse(event) {
|
|
451
|
+
if (event.type === "click" && event.button === "left" && event.y === this.bottomBorderRow) {
|
|
452
|
+
const chip = this.chipRanges.find((range) => event.x >= range.start && event.x < range.end);
|
|
453
|
+
if (chip) {
|
|
454
|
+
this.onChipClick?.(chip.chip);
|
|
455
|
+
return { handled: true, focus: true };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (this.bottomBorderRow === -1)
|
|
459
|
+
return super.handleMouse(event);
|
|
460
|
+
const y = event.y <= this.bottomBorderRow ? event.y - 1 : event.y - 2;
|
|
461
|
+
const x = event.y >= 2 && event.y <= this.bottomBorderRow ? event.x - 1 : event.x;
|
|
462
|
+
return super.handleMouse({ ...event, x, y });
|
|
463
|
+
}
|
|
464
|
+
render(width) {
|
|
465
|
+
// The frame owns one column on each side.
|
|
466
|
+
const inner = Math.max(4, width - 2);
|
|
467
|
+
const side = (line) => `${this.borderColor("│")}${line}${this.borderColor("│")}`;
|
|
468
|
+
// Reset before the base render — renderBottomBorder (called inside
|
|
469
|
+
// super.render) refills it, so a stale value can never match.
|
|
470
|
+
this.lastBottomBorder = "";
|
|
471
|
+
const lines = super.render(inner);
|
|
472
|
+
const bottom = this.lastBottomBorder
|
|
473
|
+
? lines.findIndex((line, index) => index > 0 && line === this.lastBottomBorder)
|
|
474
|
+
: -1;
|
|
475
|
+
if (bottom === -1) {
|
|
476
|
+
this.bottomBorderRow = -1;
|
|
477
|
+
return lines.map((line, i) => (i > 0 ? side(line) : line));
|
|
478
|
+
}
|
|
479
|
+
// One blank row of vertical padding just inside the frame, on each side.
|
|
480
|
+
this.bottomBorderRow = bottom + 2;
|
|
481
|
+
const padding = side(" ".repeat(inner));
|
|
482
|
+
return [
|
|
483
|
+
lines[0],
|
|
484
|
+
padding,
|
|
485
|
+
...lines.slice(1, bottom).map(side),
|
|
486
|
+
padding,
|
|
487
|
+
...lines.slice(bottom), // bottom edge and the autocomplete list
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
492
|
+
/** Left indent of the footer's item row (also the ASK/AUTO badge's column). */
|
|
493
|
+
const FOOTER_INDENT = 4;
|
|
494
|
+
export class FooterComponent {
|
|
495
|
+
state;
|
|
496
|
+
onApprovalClick;
|
|
497
|
+
/** Fired when the ASK/AUTO badge is clicked. */
|
|
498
|
+
constructor(state, onApprovalClick) {
|
|
499
|
+
this.state = state;
|
|
500
|
+
this.onApprovalClick = onApprovalClick;
|
|
501
|
+
}
|
|
502
|
+
invalidate() { }
|
|
503
|
+
handleMouse(event) {
|
|
504
|
+
if (event.type !== "click" || event.button !== "left" || !this.onApprovalClick)
|
|
505
|
+
return undefined;
|
|
506
|
+
// The badge is the leftmost item of the row.
|
|
507
|
+
const badge = this.state.approval === "auto" ? "AUTO" : "ASK";
|
|
508
|
+
if (event.x >= FOOTER_INDENT && event.x < FOOTER_INDENT + badge.length) {
|
|
509
|
+
this.onApprovalClick();
|
|
510
|
+
return { handled: true };
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
render(width) {
|
|
515
|
+
const dim = GRAY_DIM_COLOR;
|
|
516
|
+
// AUTO runs unattended: amber, so it stands out next to the neutral ASK.
|
|
517
|
+
const approval = this.state.approval === "auto" ? AUTO_COLOR("AUTO") : GRAY_COLOR("ASK");
|
|
518
|
+
const status = this.state.pendingQuit
|
|
519
|
+
? style.yellow("Ctrl+C again to quit · /quit")
|
|
520
|
+
: this.state.runState === "running"
|
|
521
|
+
? style.yellow(`${SPINNER[this.state.spinnerFrame % SPINNER.length]} working`)
|
|
522
|
+
: this.state.runState === "stopping"
|
|
523
|
+
? style.yellow("stopping…")
|
|
524
|
+
: dim("idle");
|
|
525
|
+
const tokens = dim(`↑${formatTokens(this.state.usage?.input ?? 0)} ↓${formatTokens(this.state.usage?.output ?? 0)}`);
|
|
526
|
+
const cost = formatCost(this.state.usage);
|
|
527
|
+
const path = this.compactPath(this.state.workspace);
|
|
528
|
+
// Run state, single-run tokens and cost follow the approval badge; the
|
|
529
|
+
// workspace path is flush right.
|
|
530
|
+
const left = [approval, status, tokens, cost ? dim(cost) : ""].filter(Boolean).join(dim(" · "));
|
|
531
|
+
const right = dim(this.state.branch ? `${path}:${this.state.branch}` : path);
|
|
532
|
+
const rightMargin = 4;
|
|
533
|
+
const gap = width - FOOTER_INDENT - rightMargin - visibleWidth(left) - visibleWidth(right);
|
|
534
|
+
if (gap >= 1) {
|
|
535
|
+
return [truncateToWidth(`${" ".repeat(FOOTER_INDENT)}${left}${" ".repeat(gap)}${right}`, width)];
|
|
536
|
+
}
|
|
537
|
+
return [truncateToWidth(`${" ".repeat(FOOTER_INDENT)}${left}${dim(" · ")}${right}`, width)];
|
|
538
|
+
}
|
|
539
|
+
compactPath(path) {
|
|
540
|
+
const home = process.env.HOME;
|
|
541
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Draw one popup card: a rounded frame with the title in the top edge and an
|
|
546
|
+
* optional hint in the bottom edge. Every line is padded to exactly `width`
|
|
547
|
+
* (and tinted) because pi-tui composites overlays over the base content and
|
|
548
|
+
* fills only the columns outside each rendered line.
|
|
549
|
+
*/
|
|
550
|
+
function renderCard(width, title, body, options) {
|
|
551
|
+
const bg = options.bg?.();
|
|
552
|
+
const inner = Math.max(1, width - 4); // two frame columns + one padding column each side
|
|
553
|
+
const fill = (text) => paintLine(text, width, bg);
|
|
554
|
+
const edge = (corners, label, labelStyle) => {
|
|
555
|
+
const available = Math.max(0, width - 2);
|
|
556
|
+
if (!label)
|
|
557
|
+
return fill(options.frame(`${corners[0]}${"─".repeat(available)}${corners[1]}`));
|
|
558
|
+
const labelWidth = visibleWidth(label);
|
|
559
|
+
const rules = available - labelWidth - 3; // "─ " + label + " " + trailing rules
|
|
560
|
+
if (rules < 1) {
|
|
561
|
+
return fill(options.frame(`${corners[0]}${truncateToWidth(` ${labelStyle(label)} `, available, "…", true)}${corners[1]}`));
|
|
562
|
+
}
|
|
563
|
+
return fill(options.frame(`${corners[0]}─ ${labelStyle(label)} ${"─".repeat(rules)}${corners[1]}`));
|
|
564
|
+
};
|
|
565
|
+
const lines = [edge(["╭", "╮"], title, style.bold)];
|
|
566
|
+
const blank = fill(`${options.frame("│")} ${" ".repeat(inner)} ${options.frame("│")}`);
|
|
567
|
+
lines.push(blank);
|
|
568
|
+
for (const line of body) {
|
|
569
|
+
const content = truncateToWidth(line, inner, "…", true);
|
|
570
|
+
const pad = " ".repeat(Math.max(0, inner - visibleWidth(content)));
|
|
571
|
+
lines.push(fill(`${options.frame("│")} ${content}${pad} ${options.frame("│")}`));
|
|
572
|
+
}
|
|
573
|
+
lines.push(blank);
|
|
574
|
+
lines.push(edge(["╰", "╯"], options.hint ?? "", style.dim));
|
|
575
|
+
return lines;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* A pending approval, floating between the transcript and the prompt (NOT a
|
|
579
|
+
* modal overlay — the editor keeps focus, so typing stays possible while the
|
|
580
|
+
* user decides). Answered with ctrl+y / ctrl+n from the app's global key
|
|
581
|
+
* listener; the card itself is render-only.
|
|
582
|
+
*/
|
|
583
|
+
export class ApprovalFloatComponent {
|
|
584
|
+
request = null;
|
|
585
|
+
invalidate() { }
|
|
586
|
+
/** Show (or clear) the approval being decided. */
|
|
587
|
+
set(request) {
|
|
588
|
+
this.request = request;
|
|
589
|
+
}
|
|
590
|
+
render(width) {
|
|
591
|
+
const request = this.request;
|
|
592
|
+
if (!request)
|
|
593
|
+
return [];
|
|
594
|
+
// Inset to align with the prompt box (Box(2, 0)), like the steer float.
|
|
595
|
+
const inset = 2;
|
|
596
|
+
const budget = Math.max(4, width - inset);
|
|
597
|
+
// The tool name and summary are derived from model-authored arguments
|
|
598
|
+
// (a shell command, a path): unsanitized they could rewrite or hide the
|
|
599
|
+
// very line the user is about to approve.
|
|
600
|
+
const head = truncateToWidth(sanitizeForTerminal(`⏸ Approve ${request.tool}? ${request.summary}`), budget, "…", true);
|
|
601
|
+
const hint = "ctrl+y approve · ctrl+n deny";
|
|
602
|
+
return [
|
|
603
|
+
`${" ".repeat(inset)}${style.yellow(style.bold(head))}`,
|
|
604
|
+
`${" ".repeat(inset)}${GRAY_DIM_COLOR(style.dim(hint))}`,
|
|
605
|
+
];
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
export class InfoOverlay {
|
|
609
|
+
title;
|
|
610
|
+
body;
|
|
611
|
+
close;
|
|
612
|
+
options;
|
|
613
|
+
constructor(title, body, close, options = {}) {
|
|
614
|
+
this.title = title;
|
|
615
|
+
this.body = body;
|
|
616
|
+
this.close = close;
|
|
617
|
+
this.options = options;
|
|
618
|
+
}
|
|
619
|
+
handleInput(data) {
|
|
620
|
+
if (data === "\u001b" || data === "q" || data === "\r" || data === "\n" || data === " ")
|
|
621
|
+
this.close();
|
|
622
|
+
}
|
|
623
|
+
invalidate() { }
|
|
624
|
+
render(width) {
|
|
625
|
+
return renderCard(width, this.title, this.body, {
|
|
626
|
+
frame: style.cyan,
|
|
627
|
+
bg: this.options.bg,
|
|
628
|
+
hint: this.options.hint ?? "Esc / q to close",
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Scrollable popup for a single file's unified diff. Overlays are clipped, not
|
|
634
|
+
* scrolled by the framework, so the window is managed here: the popup renders
|
|
635
|
+
* exactly `viewportHeight() + 2` lines (top edge, body, bottom edge) and moves the
|
|
636
|
+
* window with the arrow/page keys.
|
|
637
|
+
*/
|
|
638
|
+
export class DiffOverlay {
|
|
639
|
+
title;
|
|
640
|
+
options;
|
|
641
|
+
scrollTop = 0;
|
|
642
|
+
mode = "inline";
|
|
643
|
+
rowCount;
|
|
644
|
+
/** Content rows shown in the viewer (file headers and hunk headers hidden). */
|
|
645
|
+
visible;
|
|
646
|
+
cache;
|
|
647
|
+
constructor(title, diff, options) {
|
|
648
|
+
this.title = title;
|
|
649
|
+
this.options = options;
|
|
650
|
+
// Diff text is raw workspace file content (git output) — sanitize before
|
|
651
|
+
// parsing so embedded OSC/CSI sequences cannot reach the terminal.
|
|
652
|
+
const lines = parseDiffLines(sanitizeForTerminal(diff));
|
|
653
|
+
const content = lines.filter((line) => line.kind !== "meta" && line.kind !== "hunk");
|
|
654
|
+
// Metadata-only diffs (binary files, mode changes) keep their notice.
|
|
655
|
+
this.visible = content.length > 0 ? content : lines;
|
|
656
|
+
this.rowCount = this.visible.length; // until the first render, inline rows
|
|
657
|
+
}
|
|
658
|
+
viewHeight() {
|
|
659
|
+
return Math.max(1, this.options.viewportHeight());
|
|
660
|
+
}
|
|
661
|
+
get maxTop() {
|
|
662
|
+
return Math.max(0, this.rowCount - this.viewHeight());
|
|
663
|
+
}
|
|
664
|
+
scrollBy(delta) {
|
|
665
|
+
this.scrollTop = Math.max(0, Math.min(this.maxTop, this.scrollTop + delta));
|
|
666
|
+
}
|
|
667
|
+
/** Paint one diff line, using the same palette as the transcript. */
|
|
668
|
+
paint(line) {
|
|
669
|
+
if (line.kind === "add")
|
|
670
|
+
return diffAdd(line.text);
|
|
671
|
+
if (line.kind === "del")
|
|
672
|
+
return diffDel(line.text);
|
|
673
|
+
return line.kind === "context" ? line.text : style.dim(line.text);
|
|
674
|
+
}
|
|
675
|
+
buildRows(contentWidth) {
|
|
676
|
+
const nums = diffNumberWidth(this.visible);
|
|
677
|
+
// " <num> │ " prefix per row; meta/hunk rows keep it blank for alignment.
|
|
678
|
+
const lead = (oldNumber, newNumber) => `${diffNumber(oldNumber, nums)} ${diffNumber(newNumber, nums)} ${style.dim("│")} `;
|
|
679
|
+
const textWidth = Math.max(4, contentWidth - nums * 2 - 4);
|
|
680
|
+
if (this.mode === "inline") {
|
|
681
|
+
return this.visible.map((line) => `${lead(line.oldLine, line.newLine)}${truncateToWidth(this.paint(line), textWidth, "…")}`);
|
|
682
|
+
}
|
|
683
|
+
return renderPairRows(this.visible, contentWidth, (line) => this.paint(line));
|
|
684
|
+
}
|
|
685
|
+
rows(contentWidth) {
|
|
686
|
+
if (this.cache && this.cache.width === contentWidth && this.cache.mode === this.mode) {
|
|
687
|
+
return this.cache.rows;
|
|
688
|
+
}
|
|
689
|
+
const rows = this.buildRows(contentWidth);
|
|
690
|
+
this.rowCount = rows.length;
|
|
691
|
+
this.cache = { width: contentWidth, mode: this.mode, rows };
|
|
692
|
+
return rows;
|
|
693
|
+
}
|
|
694
|
+
handleInput(data) {
|
|
695
|
+
if (matchesKey(data, "escape") || data === "q" || matchesKey(data, "ctrl+c")) {
|
|
696
|
+
this.options.close();
|
|
697
|
+
}
|
|
698
|
+
else if (data === "t") {
|
|
699
|
+
this.mode = this.mode === "inline" ? "pair" : "inline";
|
|
700
|
+
}
|
|
701
|
+
else if (data === "i") {
|
|
702
|
+
this.mode = "inline";
|
|
703
|
+
}
|
|
704
|
+
else if (data === "p") {
|
|
705
|
+
this.mode = "pair";
|
|
706
|
+
}
|
|
707
|
+
else if (matchesKey(data, "up")) {
|
|
708
|
+
this.scrollBy(-1);
|
|
709
|
+
}
|
|
710
|
+
else if (matchesKey(data, "down")) {
|
|
711
|
+
this.scrollBy(1);
|
|
712
|
+
}
|
|
713
|
+
else if (matchesKey(data, "pageUp")) {
|
|
714
|
+
this.scrollBy(-Math.max(1, this.viewHeight() - 2));
|
|
715
|
+
}
|
|
716
|
+
else if (matchesKey(data, "pageDown")) {
|
|
717
|
+
this.scrollBy(Math.max(1, this.viewHeight() - 2));
|
|
718
|
+
}
|
|
719
|
+
else if (matchesKey(data, "home")) {
|
|
720
|
+
this.scrollTop = 0;
|
|
721
|
+
}
|
|
722
|
+
else if (matchesKey(data, "end")) {
|
|
723
|
+
this.scrollTop = this.maxTop;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** The mouse wheel scrolls the diff like the arrow keys do. */
|
|
727
|
+
handleMouse(event) {
|
|
728
|
+
if (event.type !== "wheel" || !event.wheelDelta)
|
|
729
|
+
return undefined;
|
|
730
|
+
this.scrollBy(event.wheelDelta);
|
|
731
|
+
return { render: true };
|
|
732
|
+
}
|
|
733
|
+
invalidate() { }
|
|
734
|
+
render(width) {
|
|
735
|
+
// Full-bleed canvas, with a rounded gray card drawn inside it (the prompt
|
|
736
|
+
// editor's look) so nothing behind the viewer leaks through.
|
|
737
|
+
const inner = Math.max(24, width);
|
|
738
|
+
const margin = 2;
|
|
739
|
+
const frameWidth = Math.max(20, inner - margin * 2);
|
|
740
|
+
const contentWidth = Math.max(10, frameWidth - 4);
|
|
741
|
+
const rows = this.rows(contentWidth);
|
|
742
|
+
const view = this.viewHeight(); // live: a zoom/resize refits the card
|
|
743
|
+
const start = Math.min(this.scrollTop, Math.max(0, rows.length - view));
|
|
744
|
+
this.scrollTop = start; // keep the clamped position across resizes
|
|
745
|
+
const end = Math.min(rows.length, start + view);
|
|
746
|
+
const position = rows.length === 0
|
|
747
|
+
? "no diff"
|
|
748
|
+
: `${start + 1}-${Math.max(start + 1, end)} / ${rows.length}${this.options.truncated ? " · truncated" : ""}`;
|
|
749
|
+
const frame = GRAY_COLOR;
|
|
750
|
+
const line = (text) => truncateToWidth(`${" ".repeat(margin)}${text}${" ".repeat(margin)}`, inner, "…", true);
|
|
751
|
+
const body = (text) => line(`${frame("│")} ${truncateToWidth(text, contentWidth, "…", true)} ${frame("│")}`);
|
|
752
|
+
// A card edge is a rounded rule with its label centered in it.
|
|
753
|
+
const edgeWidth = Math.max(0, frameWidth - 2);
|
|
754
|
+
const edge = (corners, label, labelWidth) => {
|
|
755
|
+
const before = Math.max(1, Math.floor((edgeWidth - labelWidth) / 2));
|
|
756
|
+
const after = edgeWidth - before - labelWidth;
|
|
757
|
+
return after >= 0
|
|
758
|
+
? frame(`${corners[0]}${"─".repeat(before)}${label}${"─".repeat(after)}${corners[1]}`)
|
|
759
|
+
: frame(`${corners[0]}${truncateToWidth(label, edgeWidth, "…")}${corners[1]}`);
|
|
760
|
+
};
|
|
761
|
+
const title = ` ${this.title} ${position} · ${this.mode} `;
|
|
762
|
+
const titleStyled = ` ${style.bold(this.title)} ${style.dim(`${position} · ${this.mode}`)} `;
|
|
763
|
+
const topEdge = edge(["╭", "╮"], titleStyled, visibleWidth(title));
|
|
764
|
+
const hint = " ↑/↓ PgUp/PgDn · t inline/pair · Esc to close ";
|
|
765
|
+
const lines = [
|
|
766
|
+
line(topEdge),
|
|
767
|
+
...(rows.length === 0
|
|
768
|
+
? [body(style.dim("No diff available."))]
|
|
769
|
+
: rows.slice(start, end).map((row) => body(row))),
|
|
770
|
+
];
|
|
771
|
+
// The card is a fixed-height canvas: pad the body to the viewport.
|
|
772
|
+
while (lines.length < view + 1)
|
|
773
|
+
lines.push(body(""));
|
|
774
|
+
lines.push(line(edge(["╰", "╯"], hint, visibleWidth(hint))));
|
|
775
|
+
return lines;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Select list inside a popup card, usable as a focused overlay: input and
|
|
780
|
+
* mouse events are forwarded to the inner SelectList (a bare Container would
|
|
781
|
+
* swallow them), with the card's top edge and padding row skipped in the
|
|
782
|
+
* mouse coordinates (renderCard: edge, blank, then list rows).
|
|
783
|
+
*/
|
|
784
|
+
export class SelectOverlay {
|
|
785
|
+
title;
|
|
786
|
+
list;
|
|
787
|
+
options;
|
|
788
|
+
constructor(title, list, options = {}) {
|
|
789
|
+
this.title = title;
|
|
790
|
+
this.list = list;
|
|
791
|
+
this.options = options;
|
|
792
|
+
}
|
|
793
|
+
handleInput(data) {
|
|
794
|
+
this.list.handleInput(data);
|
|
795
|
+
}
|
|
796
|
+
handleMouse(event) {
|
|
797
|
+
return this.list.handleMouse({ ...event, y: event.y - 2 });
|
|
798
|
+
}
|
|
799
|
+
invalidate() {
|
|
800
|
+
this.list.invalidate();
|
|
801
|
+
}
|
|
802
|
+
render(width) {
|
|
803
|
+
if (width < 8)
|
|
804
|
+
return [truncateToWidth(this.title, Math.max(1, width))];
|
|
805
|
+
return renderCard(width, this.title, this.list.render(Math.max(1, width - 4)), {
|
|
806
|
+
frame: GRAY_COLOR,
|
|
807
|
+
bg: this.options.bg,
|
|
808
|
+
hint: this.options.hint ?? "↑/↓ · Enter · Esc",
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|