pi-jar 0.1.3 → 0.1.4
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 +33 -7
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +7 -4
- package/extensions/index.ts +55 -37
- package/package.json +1 -1
- package/src/activity-view.ts +314 -41
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate.ts +371 -65
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +34 -16
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +5 -9
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/dialogs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { todoMark, type Todo } from "./tasks.ts";
|
|
3
|
+
import { todoMark, todoProgress, todoTotals, type Todo } from "./tasks.ts";
|
|
4
4
|
import { cleanText } from "./status.ts";
|
|
5
5
|
|
|
6
6
|
const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
|
|
@@ -111,13 +111,17 @@ export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filte
|
|
|
111
111
|
render(width: number): string[] {
|
|
112
112
|
const items = filtered();
|
|
113
113
|
selected = Math.min(selected, Math.max(0, items.length - 1));
|
|
114
|
-
const
|
|
114
|
+
const all = todos();
|
|
115
|
+
const totals = todoTotals(all);
|
|
115
116
|
const start = Math.max(0, selected - 11);
|
|
116
117
|
firstVisible = start;
|
|
117
118
|
return [
|
|
118
|
-
fit(theme.fg("accent", `╭─ pi-jar to-do · ${
|
|
119
|
-
...(items.length ? items.slice(start, start + 12).map((item, index) =>
|
|
120
|
-
|
|
119
|
+
fit(theme.fg("accent", `╭─ pi-jar to-do · ${totals.total - totals.done} open · ${filter.value} ─`), width),
|
|
120
|
+
...(items.length ? items.slice(start, start + 12).map((item, index) => {
|
|
121
|
+
const progress = todoProgress(all, item.id);
|
|
122
|
+
const row = `${start + index === selected ? "❯" : " "} ${item.parentId ? " " : ""}${todoMark(item)} ${item.title}${progress.total ? ` (${progress.done}/${progress.total})` : ""}`;
|
|
123
|
+
return fit(theme.fg(start + index === selected ? "accent" : item.done ? "dim" : item.status === "in_progress" ? "warning" : "muted", row), width);
|
|
124
|
+
})
|
|
121
125
|
: [fit(theme.fg("dim", " No items in this view"), width)]),
|
|
122
126
|
fit(theme.fg("dim", "╰─ ↑↓: select · Space: check · a: add · e: edit · d: delete · f: filter · Esc: close"), width)
|
|
123
127
|
];
|
package/src/diff-view.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { diffRows, lineDiff, type ChangeTracker, type DiffRow, type FileChange } from "./changes.ts";
|
|
4
|
+
import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
|
|
4
5
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
5
6
|
|
|
6
7
|
const ACTIONS = [
|
|
@@ -146,10 +147,21 @@ export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTrac
|
|
|
146
147
|
// that file. Older Pi builds may omit toolCallId here, hence the small FIFO fallback.
|
|
147
148
|
const pending = new Map<string, string>();
|
|
148
149
|
const fallback: string[] = [];
|
|
150
|
+
// Subagent side: hand each first-edit baseline to the parent so its /diff covers our edits.
|
|
151
|
+
const reported = new Set<string>();
|
|
149
152
|
pi.on("tool_call", (event) => {
|
|
150
153
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
151
154
|
const path = (event.input as { path?: unknown }).path;
|
|
152
|
-
|
|
155
|
+
const current = tracker();
|
|
156
|
+
if (typeof path !== "string" || !path.trim() || !current?.capture(path)) return;
|
|
157
|
+
const dir = process.env[CHILD_BASELINE_ENV];
|
|
158
|
+
const abs = current.absolute(path);
|
|
159
|
+
const before = current.baseline(abs);
|
|
160
|
+
// A failed write is reported once and not retried: a later retry would share a newer baseline.
|
|
161
|
+
if (dir && before !== undefined && !reported.has(abs)) {
|
|
162
|
+
try { writeChildBaseline(dir, abs, before); }
|
|
163
|
+
catch (error) { reported.add(abs); console.error(`pi-jar: cannot share baseline for ${abs}: ${String(error)}`); }
|
|
164
|
+
}
|
|
153
165
|
const id = (event as { toolCallId?: unknown }).toolCallId;
|
|
154
166
|
if (typeof id === "string" && id) pending.set(id, path);
|
|
155
167
|
fallback.push(path);
|
package/src/flame.ts
CHANGED
|
@@ -46,9 +46,17 @@ function hexRgb(hex: string): [number, number, number] | undefined {
|
|
|
46
46
|
return [0, 2, 4].map((at) => Number.parseInt(value.slice(at, at + 2), 16)) as [number, number, number];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** 24-bit foreground prefix per hex color: the flame and Ember repaint the same ramp colors every frame. */
|
|
50
|
+
const foregrounds = new Map<string, string>();
|
|
51
|
+
|
|
49
52
|
export function rgb(hex: string, text: string): string {
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
let prefix = foregrounds.get(hex);
|
|
54
|
+
if (prefix === undefined) {
|
|
55
|
+
const color = hexRgb(hex);
|
|
56
|
+
prefix = color ? `\x1b[38;2;${color[0]};${color[1]};${color[2]}m` : "";
|
|
57
|
+
if (foregrounds.size < 64) foregrounds.set(hex, prefix);
|
|
58
|
+
}
|
|
59
|
+
return prefix ? prefix + text + "\x1b[39m" : text;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
function cell(top: number, bottom: number): string {
|
|
@@ -59,6 +67,8 @@ function cell(top: number, bottom: number): string {
|
|
|
59
67
|
if (down) return `\x1b[38;2;${down[0]};${down[1]};${down[2]}m▄\x1b[39m`;
|
|
60
68
|
return " ";
|
|
61
69
|
}
|
|
70
|
+
/** Every truecolor half-block pair, built once instead of parsing two hex colors per cell per frame. */
|
|
71
|
+
const CELLS = Array.from({ length: FLAME_RAMP.length ** 2 }, (_, index) => cell(Math.floor(index / FLAME_RAMP.length), index % FLAME_RAMP.length));
|
|
62
72
|
|
|
63
73
|
/** Terminals without 24-bit color get shaded blocks in theme colors instead. */
|
|
64
74
|
function shadedCell(top: number, bottom: number, paint: FlamePaint): string {
|
|
@@ -217,7 +227,7 @@ export class FlameSim {
|
|
|
217
227
|
const rows: string[][] = Array.from({ length: this.rows / 2 }, (_, row) => Array.from({ length: this.width }, (_, x) => {
|
|
218
228
|
const top = this.heat[row * 2 * this.width + x]!;
|
|
219
229
|
const bottom = this.heat[(row * 2 + 1) * this.width + x]!;
|
|
220
|
-
return truecolor ? cell(top, bottom) : shadedCell(top, bottom, paint);
|
|
230
|
+
return truecolor ? CELLS[top * FLAME_RAMP.length + bottom] ?? cell(top, bottom) : shadedCell(top, bottom, paint);
|
|
221
231
|
}));
|
|
222
232
|
for (const particle of this.particles) {
|
|
223
233
|
const x = Math.round(particle.x);
|
|
@@ -234,12 +244,15 @@ export class FlameSim {
|
|
|
234
244
|
}
|
|
235
245
|
}
|
|
236
246
|
|
|
237
|
-
let cached: { seed: number; sim: FlameSim } | undefined;
|
|
247
|
+
let cached: { seed: number; sim: FlameSim; frame?: number; rows?: string[] } | undefined;
|
|
238
248
|
|
|
239
249
|
/** Rendered flame rows for an absolute frame; replays deterministically for a seed. */
|
|
240
250
|
export function flameFrame(frame: number, seed = 1, paint?: FlamePaint, truecolor = supportsTruecolor()): string[] {
|
|
241
251
|
const target = Math.max(0, Math.floor(Number.isFinite(frame) ? frame : 0));
|
|
242
252
|
if (!cached || cached.seed !== seed || cached.sim.frame > target) cached = { seed, sim: new FlameSim(seed) };
|
|
243
253
|
while (cached.sim.frame < target) cached.sim.step();
|
|
244
|
-
return cached.sim.render(paint, truecolor);
|
|
254
|
+
if (!truecolor) return cached.sim.render(paint, truecolor);
|
|
255
|
+
// Truecolor rows never use `paint`, so repaints between flame ticks (typing, streaming) reuse the frame.
|
|
256
|
+
if (cached.frame !== target || !cached.rows) { cached.rows = cached.sim.render(paint, true); cached.frame = target; }
|
|
257
|
+
return cached.rows.slice();
|
|
245
258
|
}
|
package/src/footer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { sep } from "node:path";
|
|
|
3
3
|
import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { DEFAULT_FOOTER_SETTINGS, type FooterSettings } from "./footer-settings.ts";
|
|
5
5
|
import { roleFrame } from "./animations.ts";
|
|
6
|
-
import { icon, type IconKey, withIcon } from "./icons.ts";
|
|
6
|
+
import { icon, iconSet, type IconKey, withIcon } from "./icons.ts";
|
|
7
7
|
import type { RoleStatus } from "./roles.ts";
|
|
8
8
|
import { ACTIVE_STATES, cleanText, type JarStatus } from "./status.ts";
|
|
9
9
|
import type { Quota } from "./quota.ts";
|
|
@@ -53,6 +53,12 @@ const MAX_ACTIVITY_ROWS = 3;
|
|
|
53
53
|
const THINKING_ICONS: Record<string, IconKey> = {
|
|
54
54
|
minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
55
55
|
};
|
|
56
|
+
const EFFORT_COLORS = {
|
|
57
|
+
off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
|
|
58
|
+
high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
59
|
+
} as const;
|
|
60
|
+
/** Every other color the footer paints; one sample of each keys the render cache to the live theme. */
|
|
61
|
+
const FOOTER_COLORS: readonly Color[] = ["accent", "warning", "success", "error", "dim", "muted"];
|
|
56
62
|
|
|
57
63
|
/** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
|
|
58
64
|
const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
|
|
@@ -70,10 +76,15 @@ function activityRow(item: FooterActivity, width: number, theme: FooterTheme): s
|
|
|
70
76
|
const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
|
|
71
77
|
withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
|
|
72
78
|
const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
|
|
73
|
-
const
|
|
79
|
+
const headWidth = visibleWidth(head);
|
|
80
|
+
const statsWidth = visibleWidth(stats);
|
|
81
|
+
const room = width - headWidth - statsWidth - 4;
|
|
74
82
|
const detail = item.detail && room >= 6 ? " " + theme.fg("dim", truncateToWidth(cleanText(item.detail, 200), room, "…")) : "";
|
|
75
|
-
const
|
|
76
|
-
|
|
83
|
+
const detailWidth = visibleWidth(detail);
|
|
84
|
+
const gap = Math.max(1, width - headWidth - detailWidth - statsWidth);
|
|
85
|
+
const row = head + detail + (stats ? " ".repeat(gap) + stats : "");
|
|
86
|
+
// The parts are already measured; only a head and stats too wide on their own need cutting.
|
|
87
|
+
return headWidth + detailWidth + (stats ? gap + statsWidth : 0) <= width ? row : truncateToWidth(row, width);
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
function fitCwd(cwd: string, width: number): string {
|
|
@@ -94,11 +105,18 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
|
|
|
94
105
|
return renderFooterLayout(view, width, theme).lines;
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
/** Pi repaints the footer on every render (each streamed token) while its inputs change far less often. */
|
|
109
|
+
let lastFooter: { key: string; lines: string[]; hits: FooterHit[] } | undefined;
|
|
110
|
+
|
|
97
111
|
/** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
|
|
98
112
|
export function renderFooterLayout(view: FooterView, width: number, theme: FooterTheme): { lines: string[]; hits: FooterHit[] } {
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
113
|
+
const colors = FOOTER_COLORS.map((color) => theme.fg(color, "x")).join("") + (view.effort ? theme.fg(EFFORT_COLORS[view.effort], "x") : "");
|
|
114
|
+
const key = `${width}\0${iconSet()}\0${homedir()}\0${colors}\0${JSON.stringify(view)}`;
|
|
115
|
+
if (lastFooter?.key !== key) {
|
|
116
|
+
const hits: FooterHit[] = [];
|
|
117
|
+
lastFooter = { key, lines: footerLines(view, width, theme, hits), hits };
|
|
118
|
+
}
|
|
119
|
+
return { lines: lastFooter.lines.slice(), hits: lastFooter.hits.slice() };
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
|
|
@@ -123,11 +141,7 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
|
|
|
123
141
|
const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
|
|
124
142
|
const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
|
|
125
143
|
const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
|
|
126
|
-
const
|
|
127
|
-
off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
|
|
128
|
-
high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
|
|
129
|
-
} as const;
|
|
130
|
-
const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
|
|
144
|
+
const effort = show.effort && view.effort ? theme.fg(EFFORT_COLORS[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
|
|
131
145
|
const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
|
|
132
146
|
const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
|
|
133
147
|
// At narrow widths reserve the right-hand slot for critical/active status, then context.
|
|
@@ -168,12 +182,16 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
|
|
|
168
182
|
namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
|
|
169
183
|
hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
|
|
170
184
|
}
|
|
171
|
-
const
|
|
172
|
-
const
|
|
185
|
+
const namedWidth = visibleWidth(namedLeft);
|
|
186
|
+
const rightWidth = visibleWidth(right);
|
|
187
|
+
const gap = Math.max(1, contentWidth - namedWidth - rightWidth);
|
|
188
|
+
const first = namedLeft + " ".repeat(gap) + right;
|
|
189
|
+
const lines = [namedWidth + gap + rightWidth <= contentWidth ? first : truncateToWidth(first, contentWidth)];
|
|
173
190
|
if (goal) {
|
|
174
191
|
const goalPrefix = theme.fg("accent", "◎ goal") + theme.fg("dim", " · ");
|
|
175
|
-
const
|
|
176
|
-
|
|
192
|
+
const prefixWidth = visibleWidth(goalPrefix);
|
|
193
|
+
const line = goalPrefix + theme.fg("muted", truncateToWidth(goal, Math.max(0, contentWidth - prefixWidth), "…"));
|
|
194
|
+
lines.push(prefixWidth <= contentWidth ? line : truncateToWidth(line, contentWidth));
|
|
177
195
|
}
|
|
178
196
|
if (contentWidth < 52) {
|
|
179
197
|
const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
|
package/src/history-ui.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Input, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
|
|
3
|
+
import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryChunk, type HistoryItem } from "./history.ts";
|
|
4
|
+
import { RowCache } from "./row-cache.ts";
|
|
4
5
|
import { sessionBranch } from "./session-branch.ts";
|
|
5
6
|
|
|
6
7
|
/** Separate, read-only view. Pi's native transcript and session file are never modified. */
|
|
@@ -52,7 +53,15 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
52
53
|
tui.requestRender();
|
|
53
54
|
};
|
|
54
55
|
input.onEscape = () => { searching = false; input.focused = false; tui.requestRender(); };
|
|
55
|
-
|
|
56
|
+
// Every render (each streamed token while this is open) needs the selected chunk; re-slice only on change.
|
|
57
|
+
let loaded: { item: HistoryItem; offset: number; chunk: HistoryChunk } | undefined;
|
|
58
|
+
const chunks = () => {
|
|
59
|
+
const item = selectedItem();
|
|
60
|
+
if (!item) return undefined;
|
|
61
|
+
const offset = offsets.at(-1)!;
|
|
62
|
+
if (loaded?.item !== item || loaded.offset !== offset) loaded = { item, offset, chunk: historyChunk(item, offset) };
|
|
63
|
+
return loaded.chunk;
|
|
64
|
+
};
|
|
56
65
|
const moveChunk = (direction: number) => {
|
|
57
66
|
const chunk = chunks();
|
|
58
67
|
if (direction > 0 && chunk?.more && chunk.nextOffset > offsets.at(-1)!) offsets.push(chunk.nextOffset);
|
|
@@ -60,13 +69,10 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
60
69
|
detailScroll = 0;
|
|
61
70
|
expanded = true;
|
|
62
71
|
};
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return fit(theme.fg("dim", "│ ") + clipped + " ".repeat(Math.max(0, inside - visibleWidth(clipped))) + theme.fg("dim", " │"));
|
|
68
|
-
};
|
|
69
|
-
const border = (left: string, middle: string, right: string) => fit(theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right));
|
|
72
|
+
// Rows are built from exact-width parts: one truncate-and-pad pass per new row, reused while it stays on screen.
|
|
73
|
+
const framed = new RowCache();
|
|
74
|
+
const line = (content: string) => framed.get(content, (row) => theme.fg("dim", "│ ") + truncateToWidth(row, Math.max(0, width - 4), "...", true) + theme.fg("dim", " │"));
|
|
75
|
+
const border = (left: string, middle: string, right: string) => theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right);
|
|
70
76
|
return {
|
|
71
77
|
invalidate() { input.invalidate(); },
|
|
72
78
|
handleInput(data: string) {
|
|
@@ -103,11 +109,12 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
103
109
|
},
|
|
104
110
|
render(available: number): string[] {
|
|
105
111
|
width = Math.max(8, available);
|
|
112
|
+
framed.begin(`${width}\0${theme.fg("dim", "│")}`);
|
|
106
113
|
const start = first();
|
|
107
114
|
const items = current.items.slice(start, start + listRows());
|
|
108
115
|
const heading = ` pi-jar · history · ${snapshot.visible.length} turns`;
|
|
109
|
-
const top =
|
|
110
|
-
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮")
|
|
116
|
+
const top = theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, width - 6))
|
|
117
|
+
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮");
|
|
111
118
|
const subtitle = ` Active branch · page ${current.page + 1}/${current.totalPages} · snapshot at open`;
|
|
112
119
|
const rows = items.length ? items.map((item, position) => {
|
|
113
120
|
const index = start + position;
|
|
@@ -130,7 +137,7 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
|
130
137
|
const help = searching ? line(input.render(Math.max(1, width - 4))[0] ?? "/")
|
|
131
138
|
: line(theme.fg("dim", notice || (width >= 66 ? "↑↓/Pg: move · /: search · n/N: next · e: expand · d/u: detail · [ ]: chunks · p/o: pages · Esc" : "↑↓ move · / search · e expand · p/o pages · Esc")));
|
|
132
139
|
return [top, line(theme.fg("muted", subtitle)), border("├", "─", "┤"), ...rows,
|
|
133
|
-
border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")]
|
|
140
|
+
border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")];
|
|
134
141
|
}
|
|
135
142
|
};
|
|
136
143
|
}, { overlay: true, overlayOptions: { anchor: "center", width: 92, maxHeight: "80%" } });
|
package/src/history.ts
CHANGED
|
@@ -139,8 +139,10 @@ export function historyPage(snapshot: HistorySnapshot, page = 0, size = HISTORY_
|
|
|
139
139
|
return { items, page: at, totalPages };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
export interface HistoryChunk { lines: string[]; more: boolean; nextOffset: number; trailingNewline: boolean }
|
|
143
|
+
|
|
142
144
|
/** Fetch only the requested 2 KiB / 30-line segment; nextOffset never skips hidden lines. */
|
|
143
|
-
export function historyChunk(item: HistoryItem, offset = 0):
|
|
145
|
+
export function historyChunk(item: HistoryItem, offset = 0): HistoryChunk {
|
|
144
146
|
const at = Math.max(0, Math.min(200_000_000, Math.floor(offset) || 0));
|
|
145
147
|
const { raw, more } = excerpt(item.entry, HISTORY_CHUNK_SIZE, at);
|
|
146
148
|
// Limit UTF-8 bytes as well as UTF-16 code units (CJK/emoji can exceed 2 KiB).
|
package/src/model-roles.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type RoleScope = "global" | "project";
|
|
|
21
21
|
export interface RoleAssignment { provider: string; model: string; thinking?: ThinkingLevel }
|
|
22
22
|
export interface ResolvedRole extends RoleAssignment { via: string[] }
|
|
23
23
|
export interface RoleTag { name?: string; color?: string }
|
|
24
|
-
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag> }
|
|
24
|
+
export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
|
|
25
25
|
export interface RoleRow {
|
|
26
26
|
role: string; label: string; usedBy?: string; custom: boolean;
|
|
27
27
|
spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
|
|
@@ -67,6 +67,17 @@ export function parseRoleConfig(raw: unknown): RoleConfig {
|
|
|
67
67
|
}
|
|
68
68
|
if (spec) config.roles[role] = spec;
|
|
69
69
|
}
|
|
70
|
+
if (value.fallbacks && typeof value.fallbacks === "object") {
|
|
71
|
+
const fallbacks: Record<string, string[]> = {};
|
|
72
|
+
for (const [role, entries] of Object.entries(value.fallbacks)) {
|
|
73
|
+
if (!isRoleName(role) || !Array.isArray(entries)) continue;
|
|
74
|
+
fallbacks[role] = [...new Set(entries.flatMap((entry) => {
|
|
75
|
+
const spec = typeof entry === "string" ? normalizeSpec(entry) : undefined;
|
|
76
|
+
return spec ? [spec] : [];
|
|
77
|
+
}))].slice(0, 8);
|
|
78
|
+
}
|
|
79
|
+
if (Object.keys(fallbacks).length) config.fallbacks = fallbacks;
|
|
80
|
+
}
|
|
70
81
|
if (Array.isArray(value.cycleOrder)) {
|
|
71
82
|
const order = value.cycleOrder.filter((item): item is string => typeof item === "string" && isRoleName(item));
|
|
72
83
|
if (order.length) config.cycleOrder = [...new Set(order)];
|
|
@@ -143,6 +154,8 @@ export class ModelRoleManager {
|
|
|
143
154
|
private applying = false;
|
|
144
155
|
/** Bumped whenever the user picks a model or effort themselves. */
|
|
145
156
|
private manualEpoch = 0;
|
|
157
|
+
/** Last `summary()`, valid while the same (replaced-on-write) configs are loaded. */
|
|
158
|
+
private summaryMemo: { global: RoleConfig; project: RoleConfig; limit: number; text: string } | undefined;
|
|
146
159
|
|
|
147
160
|
constructor(pi: ExtensionAPI) {
|
|
148
161
|
this.pi = pi;
|
|
@@ -188,12 +201,16 @@ export class ModelRoleManager {
|
|
|
188
201
|
});
|
|
189
202
|
}
|
|
190
203
|
|
|
191
|
-
/** Summary for compact surfaces such as the welcome card. */
|
|
204
|
+
/** Summary for compact surfaces such as the welcome card, which repaints every frame. */
|
|
192
205
|
summary(limit = 3): string {
|
|
206
|
+
const memo = this.summaryMemo;
|
|
207
|
+
if (memo?.global === this.global && memo.project === this.project && memo.limit === limit) return memo.text;
|
|
193
208
|
const rows = this.list().filter((row) => row.resolved || row.spec);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
209
|
+
const text = !rows.length ? "all roles follow the current model"
|
|
210
|
+
: rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
|
|
211
|
+
+ (rows.length > limit ? ` · +${rows.length - limit}` : "");
|
|
212
|
+
this.summaryMemo = { global: this.global, project: this.project, limit, text };
|
|
213
|
+
return text;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
private status(ctx: ExtensionContext): void {
|
|
@@ -279,6 +296,32 @@ export class ModelRoleManager {
|
|
|
279
296
|
if (scope === "project") this.project = next; else this.global = next;
|
|
280
297
|
}
|
|
281
298
|
|
|
299
|
+
/** Project lists replace global lists; an empty project list disables inherited fallbacks. */
|
|
300
|
+
fallbackSpecs(role: string): string[] {
|
|
301
|
+
return [...(this.project.fallbacks?.[role] ?? this.global.fallbacks?.[role] ?? [])];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
resolveSpec(spec: string): ResolvedRole {
|
|
305
|
+
const result = resolveRole({ ...this.merged(), __fallback__: spec }, "__fallback__");
|
|
306
|
+
if (!result || "error" in result) throw new Error(result?.error ?? "Invalid fallback model");
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
updateFallbacks(role: string, specs: string[], scope: RoleScope = "global"): void {
|
|
311
|
+
if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
|
|
312
|
+
if (specs.length > 8) throw new Error("At most 8 fallback models are allowed");
|
|
313
|
+
const normalized = specs.map((spec) => {
|
|
314
|
+
const target = normalizeSpec(spec);
|
|
315
|
+
if (!target) throw new Error("Invalid fallback target: " + spec);
|
|
316
|
+
return target;
|
|
317
|
+
});
|
|
318
|
+
if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
|
|
319
|
+
const target = scope === "project" ? this.project : this.global;
|
|
320
|
+
const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
|
|
321
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
|
|
322
|
+
if (scope === "project") this.project = next; else this.global = next;
|
|
323
|
+
}
|
|
324
|
+
|
|
282
325
|
register(openUi?: (ctx: ExtensionContext) => Promise<void>): void {
|
|
283
326
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
284
327
|
if (this.applying) return;
|
|
@@ -294,13 +337,25 @@ export class ModelRoleManager {
|
|
|
294
337
|
});
|
|
295
338
|
|
|
296
339
|
this.pi.registerCommand("roles", {
|
|
297
|
-
description: "Configure model roles
|
|
340
|
+
description: "Configure model roles: /roles, /roles set ROLE provider/model[:effort]|@role, /roles fallback ROLE MODEL...|clear, /roles <role>",
|
|
298
341
|
handler: async (args, ctx) => {
|
|
299
342
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
300
343
|
const verb = (parts[0] ?? "").toLowerCase();
|
|
301
344
|
if (!verb || verb === "list") {
|
|
302
345
|
if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
|
|
303
|
-
ctx.ui.notify(this.list().map(describeRole).
|
|
346
|
+
ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
347
|
+
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (verb === "fallback") {
|
|
351
|
+
const role = parts[1] ?? "";
|
|
352
|
+
const scope: RoleScope = parts.includes("--project") ? "project" : "global";
|
|
353
|
+
const targets = parts.slice(2).filter((part) => part !== "--project");
|
|
354
|
+
if (!isRoleName(role)) { ctx.ui.notify("Usage: /roles fallback ROLE [PROVIDER/MODEL[:effort]|@ROLE ...|clear] [--project]", "error"); return; }
|
|
355
|
+
if (!targets.length) { ctx.ui.notify(role + " fallbacks: " + (this.fallbackSpecs(role).join(" → ") || "none"), "info"); return; }
|
|
356
|
+
try { this.updateFallbacks(role, targets.length === 1 && targets[0] === "clear" ? [] : targets, scope); }
|
|
357
|
+
catch (error) { ctx.ui.notify("Could not save fallbacks: " + (error as Error).message, "error"); return; }
|
|
358
|
+
ctx.ui.notify("Saved " + role + " fallbacks (" + scope + ")", "info");
|
|
304
359
|
return;
|
|
305
360
|
}
|
|
306
361
|
if (verb === "cycle") { await this.cycle(ctx); return; }
|
package/src/panel.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { RowCache } from "./row-cache.ts";
|
|
3
4
|
|
|
4
5
|
export type Paint = (color: string, text: string) => string;
|
|
5
6
|
export interface PanelTab { name: string; render(width: number, fg: Paint): string[] }
|
|
@@ -13,6 +14,8 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
|
|
|
13
14
|
let body: string[] = [];
|
|
14
15
|
let rows = 10;
|
|
15
16
|
let tabHits: { start: number; end: number }[] = [];
|
|
17
|
+
// The panel repaints on every Pi render while open; its body rows rarely change between frames.
|
|
18
|
+
const framed = new RowCache();
|
|
16
19
|
const fg: Paint = (color, text) => theme.fg(color as never, text);
|
|
17
20
|
const move = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, body.length - rows), scroll + delta)); };
|
|
18
21
|
const select = (index: number) => { tab = (index + tabs.length) % tabs.length; scroll = 0; };
|
|
@@ -40,7 +43,8 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
|
|
|
40
43
|
rows = Math.max(4, (process.stdout.rows ?? 24) - 6);
|
|
41
44
|
body = tabs[tab]!.render(inner, fg);
|
|
42
45
|
scroll = Math.min(scroll, Math.max(0, body.length - rows));
|
|
43
|
-
|
|
46
|
+
framed.begin(`${inner}\0${fg("dim", "│")}`);
|
|
47
|
+
const line = (text: string) => framed.get(text, (row) => fg("dim", "│ ") + truncateToWidth(row, inner, "...", true) + fg("dim", " │"));
|
|
44
48
|
tabHits = [];
|
|
45
49
|
// Every tab label is name + 2 cells wide ("[name]" or " name "), separated by one space.
|
|
46
50
|
let x = 2;
|
package/src/prompt-search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { fuzzyFilter, Key, matchesKey, truncateToWidth,
|
|
2
|
+
import { fuzzyFilter, Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { cleanText } from "./status.ts";
|
|
4
4
|
|
|
5
5
|
export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
|
|
@@ -58,6 +58,13 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
|
|
|
58
58
|
let visible: PastPrompt[] = pool;
|
|
59
59
|
const rows = 10;
|
|
60
60
|
const filter = () => { visible = query ? fuzzyFilter(pool, query, (item) => item.text) : pool; selected = 0; first = 0; };
|
|
61
|
+
// Prompts can be several KiB; sanitize each once, not on every repaint.
|
|
62
|
+
const cleaned = new Map<PastPrompt, string>();
|
|
63
|
+
const preview = (item: PastPrompt) => {
|
|
64
|
+
let text = cleaned.get(item);
|
|
65
|
+
if (text === undefined) cleaned.set(item, text = cleanText(item.text, 400));
|
|
66
|
+
return text;
|
|
67
|
+
};
|
|
61
68
|
return {
|
|
62
69
|
invalidate() {},
|
|
63
70
|
handleInput(data: string) {
|
|
@@ -81,21 +88,22 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
|
|
|
81
88
|
const inner = w - 4;
|
|
82
89
|
if (selected < first) first = selected;
|
|
83
90
|
if (selected >= first + rows) first = selected - rows + 1;
|
|
84
|
-
const fit = (text: string) => { const value = truncateToWidth(text, inner); return value + " ".repeat(Math.max(0, inner - visibleWidth(value))); };
|
|
85
91
|
const dim = (text: string) => theme.fg("dim", text);
|
|
92
|
+
// Rows are exactly `w` wide; only the two borders can overflow a narrow overlay.
|
|
86
93
|
const lines = [
|
|
87
|
-
theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"),
|
|
88
|
-
dim("│ ") +
|
|
94
|
+
truncateToWidth(theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"), w),
|
|
95
|
+
dim("│ ") + truncateToWidth(theme.fg("accent", "› ") + query + "\x1b[7m \x1b[0m" + dim(` ${visible.length}/${prompts.length}`), inner, "...", true) + dim(" │")
|
|
89
96
|
];
|
|
90
97
|
for (let row = 0; row < rows; row++) {
|
|
91
98
|
const item = visible[first + row];
|
|
92
99
|
const active = first + row === selected;
|
|
93
|
-
const text = item ? (active ? "❯ " : " ") +
|
|
100
|
+
const text = item ? (active ? "❯ " : " ") + preview(item) : "";
|
|
94
101
|
const tag = item?.source === "earlier session" ? dim(" · earlier") : "";
|
|
95
|
-
|
|
102
|
+
const content = item ? theme.fg(active ? "accent" : "muted", truncateToWidth(text, Math.max(4, inner - 10))) + tag : "";
|
|
103
|
+
lines.push(dim("│ ") + truncateToWidth(content, inner, "...", true) + dim(" │"));
|
|
96
104
|
}
|
|
97
|
-
lines.push(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"));
|
|
98
|
-
return lines
|
|
105
|
+
lines.push(truncateToWidth(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"), w));
|
|
106
|
+
return lines;
|
|
99
107
|
}
|
|
100
108
|
};
|
|
101
109
|
}, { overlay: true, overlayOptions: { width: "80%", maxHeight: "60%" } });
|
package/src/row-cache.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row memo across consecutive renders. pi-tui re-segments every grapheme on each truncate, and pi-jar's
|
|
3
|
+
* chrome and overlays repaint on every Pi render (each streamed token) with mostly the same rows, or one
|
|
4
|
+
* changed row while typing or scrolling. Rows transformed by the previous render are reused while the
|
|
5
|
+
* scope (width, painter sample) holds, so memory stays bounded by two renders' rows.
|
|
6
|
+
*/
|
|
7
|
+
export class RowCache {
|
|
8
|
+
private previous = new Map<string, string>();
|
|
9
|
+
private current = new Map<string, string>();
|
|
10
|
+
private scope: string | undefined;
|
|
11
|
+
|
|
12
|
+
/** Start a render. A different scope drops every cached row. */
|
|
13
|
+
begin(scope: string): void {
|
|
14
|
+
this.previous = scope === this.scope ? this.current : new Map();
|
|
15
|
+
this.current = new Map();
|
|
16
|
+
this.scope = scope;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `transform(row)` for this render; `transform` may depend only on the row and the scope. */
|
|
20
|
+
get(row: string, transform: (row: string) => string): string {
|
|
21
|
+
let value = this.current.get(row) ?? this.previous.get(row);
|
|
22
|
+
if (value === undefined) value = transform(row);
|
|
23
|
+
this.current.set(row, value);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/session-ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext, SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { sessionDisplayName } from "./composer.ts";
|
|
4
|
-
import { withIcon } from "./icons.ts";
|
|
4
|
+
import { iconSet, withIcon } from "./icons.ts";
|
|
5
5
|
import { contentRows, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
6
|
import { cleanText } from "./status.ts";
|
|
7
7
|
|
|
@@ -68,8 +68,13 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
|
|
|
68
68
|
const settle = (value: SessionDetails) => { if (closed) return; cache.set(session.path, value); tui.requestRender(); };
|
|
69
69
|
Promise.resolve().then(() => details(session)).then((value) => settle(value ?? {}), () => settle({}));
|
|
70
70
|
};
|
|
71
|
+
// Wrapping a 4 KiB first prompt is the costliest part of a frame; redo it only when its inputs change.
|
|
72
|
+
let described: { session: SessionInfo; inputs: string; loaded: SessionDetails | null | undefined; lines: string[] } | undefined;
|
|
71
73
|
const describe = (session: SessionInfo, bodyWidth: number, height: number): string[] => {
|
|
72
74
|
const date = session.modified;
|
|
75
|
+
const inputs = `${bodyWidth}\0${height}\0${age(date)}\0${iconSet()}`;
|
|
76
|
+
const loaded = details ? cache.get(session.path) : undefined;
|
|
77
|
+
if (described?.session === session && described.inputs === inputs && described.loaded === loaded) return described.lines;
|
|
73
78
|
const when = Number.isNaN(date.getTime()) ? "unknown"
|
|
74
79
|
: `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}` + (age(date) ? ` · ${age(date)} ago` : "");
|
|
75
80
|
const lines = [
|
|
@@ -80,7 +85,6 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
|
|
|
80
85
|
];
|
|
81
86
|
if (session.cwd) lines.push(fg("muted", withIcon("folder", cleanText(session.cwd, 400))));
|
|
82
87
|
if (details) {
|
|
83
|
-
const loaded = cache.get(session.path);
|
|
84
88
|
if (!loaded) lines.push(fg("dim", "Loading details…"));
|
|
85
89
|
else {
|
|
86
90
|
for (const [key, text] of [["goal", loaded.goal], ["plan", loaded.plan]] as const) {
|
|
@@ -91,10 +95,11 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
|
|
|
91
95
|
lines.push("", fg("dim", "First prompt"));
|
|
92
96
|
const prompt = cleanText(session.firstMessage, 4096);
|
|
93
97
|
lines.push(...(prompt ? wrapTextWithAnsi(prompt, Math.max(1, bodyWidth)).map((line) => fg("muted", line)) : [fg("dim", "No first prompt")]));
|
|
94
|
-
|
|
98
|
+
described = { session, inputs, loaded, lines: lines.slice(0, height).map((line) => truncateToWidth(line, Math.max(0, bodyWidth))) };
|
|
99
|
+
return described.lines;
|
|
95
100
|
};
|
|
96
101
|
return {
|
|
97
|
-
invalidate() {},
|
|
102
|
+
invalidate() { described = undefined; },
|
|
98
103
|
handleInput(data: string) {
|
|
99
104
|
if (matchesKey(data, Key.escape)) return finish(undefined);
|
|
100
105
|
if (matchesKey(data, Key.enter)) return finish(rows[selected]?.path);
|
package/src/settings-ui.ts
CHANGED
|
@@ -111,12 +111,8 @@ export async function openJarSettings(
|
|
|
111
111
|
render(available: number): string[] {
|
|
112
112
|
width = Math.max(8, available);
|
|
113
113
|
const state = current();
|
|
114
|
-
|
|
115
|
-
const line = (text: string) =>
|
|
116
|
-
const inner = Math.max(0, width - 5);
|
|
117
|
-
const content = truncateToWidth(text, inner);
|
|
118
|
-
return fit(theme.fg("dim", "│ ") + content + " ".repeat(Math.max(0, inner - visibleWidth(content))) + theme.fg("dim", " │"));
|
|
119
|
-
};
|
|
114
|
+
// Rows are assembled from exact-width parts, so one truncate-and-pad pass per row is enough.
|
|
115
|
+
const line = (text: string) => theme.fg("dim", "│ ") + truncateToWidth(text, Math.max(0, width - 5), "...", true) + theme.fg("dim", " │");
|
|
120
116
|
const title = " pi-jar · settings ";
|
|
121
117
|
const top = theme.fg("accent", "╭─" + truncateToWidth(title, Math.max(0, width - 6))
|
|
122
118
|
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
|
|
@@ -144,13 +140,13 @@ export async function openJarSettings(
|
|
|
144
140
|
];
|
|
145
141
|
const start = firstVisible();
|
|
146
142
|
const rows = allRows.slice(start, start + pageSize());
|
|
147
|
-
return [
|
|
143
|
+
return [top, tabs, divider,
|
|
148
144
|
line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
|
|
149
145
|
line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
150
146
|
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
|
|
151
|
-
...rows
|
|
147
|
+
...rows,
|
|
152
148
|
line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
153
|
-
|
|
149
|
+
theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
|
|
154
150
|
}
|
|
155
151
|
};
|
|
156
152
|
});
|
package/src/shells.ts
CHANGED
|
@@ -53,10 +53,11 @@ export class ShellManager {
|
|
|
53
53
|
constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
|
|
54
54
|
|
|
55
55
|
list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
|
|
56
|
+
/** Read by the footer on every render: one shallow copy per job, never the output lines. */
|
|
56
57
|
summaries(): Array<Omit<ShellJob, "lines">> {
|
|
57
58
|
return [...this.jobs.values()].map(({ job }) => {
|
|
58
59
|
const { lines: _lines, ...summary } = job;
|
|
59
|
-
return
|
|
60
|
+
return summary;
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
|