pi-jar 0.1.2 → 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/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
- const line = (text: string) => fg("dim", "│ ") + truncateToWidth(text, inner) + " ".repeat(Math.max(0, inner - visibleWidth(truncateToWidth(text, inner)))) + fg("dim", " │");
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/plan.ts CHANGED
@@ -75,6 +75,7 @@ export class PlanMode {
75
75
  private reminders = 0;
76
76
  private reviewing = false;
77
77
  private compacting = false;
78
+ private contextDirty = true;
78
79
  private compactGeneration = 0;
79
80
  private executionRestore: (() => Promise<void>) | undefined;
80
81
  private executionRunning = false;
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { fuzzyFilter, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
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 }
@@ -53,9 +53,18 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
53
53
  let query = "";
54
54
  let selected = 0;
55
55
  let first = 0;
56
- let visible: PastPrompt[] = prompts.slice();
56
+ // pi-tui's fuzzyFilter takes a mutable array; copy once per overlay, not per keystroke.
57
+ const pool: PastPrompt[] = prompts.slice();
58
+ let visible: PastPrompt[] = pool;
57
59
  const rows = 10;
58
- const filter = () => { visible = query ? fuzzyFilter(prompts, query, (item) => item.text) : prompts.slice(); selected = 0; first = 0; };
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
+ };
59
68
  return {
60
69
  invalidate() {},
61
70
  handleInput(data: string) {
@@ -79,21 +88,22 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
79
88
  const inner = w - 4;
80
89
  if (selected < first) first = selected;
81
90
  if (selected >= first + rows) first = selected - rows + 1;
82
- const fit = (text: string) => { const value = truncateToWidth(text, inner); return value + " ".repeat(Math.max(0, inner - visibleWidth(value))); };
83
91
  const dim = (text: string) => theme.fg("dim", text);
92
+ // Rows are exactly `w` wide; only the two borders can overflow a narrow overlay.
84
93
  const lines = [
85
- theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"),
86
- dim("│ ") + fit(theme.fg("accent", "› ") + query + "\x1b[7m \x1b[0m" + dim(` ${visible.length}/${prompts.length}`)) + 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(" │")
87
96
  ];
88
97
  for (let row = 0; row < rows; row++) {
89
98
  const item = visible[first + row];
90
99
  const active = first + row === selected;
91
- const text = item ? (active ? "❯ " : " ") + cleanText(item.text, 400) : "";
100
+ const text = item ? (active ? "❯ " : " ") + preview(item) : "";
92
101
  const tag = item?.source === "earlier session" ? dim(" · earlier") : "";
93
- lines.push(dim("│ ") + fit(item ? theme.fg(active ? "accent" : "muted", truncateToWidth(text, Math.max(4, inner - 10))) + tag : "") + dim(" │"));
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(" │"));
94
104
  }
95
- lines.push(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"));
96
- return lines.map((line) => truncateToWidth(line, w));
105
+ lines.push(truncateToWidth(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"), w));
106
+ return lines;
97
107
  }
98
108
  };
99
109
  }, { overlay: true, overlayOptions: { width: "80%", maxHeight: "60%" } });
@@ -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
+ }
@@ -37,6 +37,13 @@ export function sessionWorkflow(jsonl: string): { goal?: string; plan?: string }
37
37
  ...(plan ? { plan } : {}) };
38
38
  }
39
39
 
40
+ /** Goal and plan recorded in one session file; empty for oversized or unreadable files. */
41
+ export async function sessionDetails(path: string, read: (path: string) => Promise<string> = (file) => readFile(file, "utf8"),
42
+ size: (path: string) => Promise<number> = async (file) => (await stat(file)).size): Promise<{ goal?: string; plan?: string }> {
43
+ try { return await size(path) <= MAX_SCAN_BYTES ? sessionWorkflow(await read(path)) : {}; }
44
+ catch { return {}; }
45
+ }
46
+
40
47
  /** Most recent sessions for the project other than the current one, with their goal and plan. */
41
48
  export async function recentSessions(sessions: readonly SessionInfo[], current: string | undefined, limit = RECENT_SESSIONS,
42
49
  read: (path: string) => Promise<string> = (path) => readFile(path, "utf8"), size: (path: string) => Promise<number> = async (path) => (await stat(path)).size): Promise<RecentSession[]> {
@@ -44,9 +51,7 @@ export async function recentSessions(sessions: readonly SessionInfo[], current:
44
51
  .sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, limit);
45
52
  return Promise.all(picked.map(async (session) => {
46
53
  const title = session.name ? cleanText(session.name, 60) : cleanText(session.firstMessage || sessionDisplayName(undefined, session.id), 60);
47
- let workflow: { goal?: string; plan?: string } = {};
48
- try { if (await size(session.path) <= MAX_SCAN_BYTES) workflow = sessionWorkflow(await read(session.path)); }
49
- catch { /* unreadable session: show it without details */ }
54
+ const workflow = await sessionDetails(session.path, read, size);
50
55
  return { path: session.path, title, modified: session.modified, messages: session.messageCount, ...workflow };
51
56
  }));
52
57
  }
package/src/session-ui.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { ExtensionContext, SessionInfo } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { sessionDisplayName } from "./composer.ts";
4
+ import { iconSet, withIcon } from "./icons.ts";
5
+ import { contentRows, sidebarWidth, splitFrame } from "./split-view.ts";
4
6
  import { cleanText } from "./status.ts";
5
7
 
8
+ /** Extra per-session facts that need reading the session file; loaded lazily for the selected row only. */
9
+ export interface SessionDetails { goal?: string; plan?: string }
10
+
6
11
  /** Search session titles and first prompts, without exposing raw JSONL contents in the picker. */
7
12
  export function filterSessions(sessions: readonly SessionInfo[], query: string): SessionInfo[] {
8
13
  const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
@@ -12,7 +17,22 @@ export function filterSessions(sessions: readonly SessionInfo[], query: string):
12
17
  });
13
18
  }
14
19
 
15
- export async function pickSession(ctx: ExtensionContext, sessions: readonly SessionInfo[], initial = ""): Promise<string | undefined> {
20
+ const two = (value: number) => String(value).padStart(2, "0");
21
+ function age(date: Date, now = Date.now()): string {
22
+ if (Number.isNaN(date.getTime())) return "";
23
+ const minutes = Math.max(0, Math.floor((now - date.getTime()) / 60_000));
24
+ if (minutes < 60) return `${minutes}m`;
25
+ if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
26
+ if (minutes < 43_200) return `${Math.floor(minutes / 1440)}d`;
27
+ return minutes < 525_600 ? `${Math.floor(minutes / 43_200)}mo` : `${Math.floor(minutes / 525_600)}y`;
28
+ }
29
+
30
+ // Chrome rows: top border, divider, search row, hint row, bottom border.
31
+ const CHROME = 5;
32
+
33
+ /** Overlay: searchable sessions on the left, the selected session's details on the right; resolves to the chosen path. */
34
+ export async function pickSession(ctx: ExtensionContext, sessions: readonly SessionInfo[], initial = "",
35
+ details?: (session: SessionInfo) => Promise<SessionDetails>): Promise<string | undefined> {
16
36
  if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
17
37
  const indexed = sessions.map((session) => ({
18
38
  session,
@@ -30,56 +50,106 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
30
50
  let selected = 0;
31
51
  let first = 0;
32
52
  let width = 80;
53
+ let listRows = contentRows(CHROME, 4);
54
+ let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
33
55
  let rows = search(query);
34
- const pageSize = () => Math.max(2, Math.min(10, Math.floor(((process.stdout.rows ?? 24) - 7) / 2)));
56
+ let closed = false;
57
+ // undefined = never requested; null = in flight. Keyed by path so re-filtering keeps loaded details.
58
+ const cache = new Map<string, SessionDetails | null>();
59
+ const fg = (color: string, text: string) => theme.fg(color as never, text);
60
+ const finish = (path: string | undefined) => { closed = true; done(path); };
61
+ const reveal = () => { first = Math.max(0, Math.min(selected, first), selected - listRows + 1); };
62
+ const move = (to: number) => { selected = Math.max(0, Math.min(rows.length - 1, to)); reveal(); tui.requestRender(); };
35
63
  const refresh = () => { rows = search(query); selected = 0; first = 0; tui.requestRender(); };
64
+ const load = (session: SessionInfo) => {
65
+ if (!details || cache.has(session.path)) return;
66
+ cache.set(session.path, null);
67
+ // Never awaited: input stays live while the file is read, and late results after close are dropped.
68
+ const settle = (value: SessionDetails) => { if (closed) return; cache.set(session.path, value); tui.requestRender(); };
69
+ Promise.resolve().then(() => details(session)).then((value) => settle(value ?? {}), () => settle({}));
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;
73
+ const describe = (session: SessionInfo, bodyWidth: number, height: number): string[] => {
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;
78
+ const when = Number.isNaN(date.getTime()) ? "unknown"
79
+ : `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}` + (age(date) ? ` · ${age(date)} ago` : "");
80
+ const lines = [
81
+ fg("accent", truncateToWidth(cleanText(sessionDisplayName(session.name, session.id), 200), bodyWidth)),
82
+ fg("muted", withIcon("session", cleanText(session.id, 36).slice(0, 8))),
83
+ fg("muted", withIcon("time", when)),
84
+ fg("muted", `${session.messageCount} message${session.messageCount === 1 ? "" : "s"}`)
85
+ ];
86
+ if (session.cwd) lines.push(fg("muted", withIcon("folder", cleanText(session.cwd, 400))));
87
+ if (details) {
88
+ if (!loaded) lines.push(fg("dim", "Loading details…"));
89
+ else {
90
+ for (const [key, text] of [["goal", loaded.goal], ["plan", loaded.plan]] as const) {
91
+ if (text?.trim()) lines.push(...wrapTextWithAnsi(withIcon(key, cleanText(text, 600)), Math.max(1, bodyWidth)).slice(0, 3).map((line) => fg("text", line)));
92
+ }
93
+ }
94
+ }
95
+ lines.push("", fg("dim", "First prompt"));
96
+ const prompt = cleanText(session.firstMessage, 4096);
97
+ lines.push(...(prompt ? wrapTextWithAnsi(prompt, Math.max(1, bodyWidth)).map((line) => fg("muted", line)) : [fg("dim", "No first prompt")]));
98
+ described = { session, inputs, loaded, lines: lines.slice(0, height).map((line) => truncateToWidth(line, Math.max(0, bodyWidth))) };
99
+ return described.lines;
100
+ };
36
101
  return {
37
- invalidate() {},
102
+ invalidate() { described = undefined; },
38
103
  handleInput(data: string) {
39
- if (matchesKey(data, Key.escape)) return done(undefined);
40
- if (matchesKey(data, Key.enter)) return done(rows[selected]?.path);
41
- if (matchesKey(data, Key.up)) selected = Math.max(0, selected - 1);
42
- else if (matchesKey(data, Key.down)) selected = Math.min(rows.length - 1, selected + 1);
43
- else if (matchesKey(data, Key.pageUp)) selected = Math.max(0, selected - pageSize());
44
- else if (matchesKey(data, Key.pageDown)) selected = Math.min(rows.length - 1, selected + pageSize());
45
- else if (matchesKey(data, Key.backspace) || data === "\x7f") { query = query.slice(0, -1); return refresh(); }
46
- else if (/^[\x20-\x7e]$/.test(data) && query.length < 100) { query += data; return refresh(); }
47
- first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
48
- tui.requestRender();
104
+ if (matchesKey(data, Key.escape)) return finish(undefined);
105
+ if (matchesKey(data, Key.enter)) return finish(rows[selected]?.path);
106
+ if (matchesKey(data, Key.up)) move(selected - 1);
107
+ else if (matchesKey(data, Key.down)) move(selected + 1);
108
+ else if (matchesKey(data, Key.pageUp)) move(selected - listRows);
109
+ else if (matchesKey(data, Key.pageDown)) move(selected + listRows);
110
+ else if (matchesKey(data, Key.backspace) || data === "\x7f") { query = query.slice(0, -1); refresh(); }
111
+ else if (/^[\x20-\x7e]$/.test(data) && query.length < 100) { query += data; refresh(); }
49
112
  },
50
113
  handleMouse(event: TuiMouseEvent) {
51
- if (event.type === "wheel" && event.wheelDelta) {
52
- selected = Math.max(0, Math.min(rows.length - 1, selected + Math.sign(event.wheelDelta)));
53
- first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
54
- tui.requestRender(); return { handled: true };
55
- }
56
- if (event.type !== "click" || event.button !== "left" || event.x >= width) return;
57
- const index = first + Math.floor((event.y - 2) / 2);
58
- const row = rows[index];
59
- if (event.y >= 2 && event.y < 2 + 2 * pageSize() && row) {
60
- selected = index; done(row.path); return { handled: true };
61
- }
114
+ if (event.type === "wheel" && event.wheelDelta) { move(selected + Math.sign(event.wheelDelta)); return { handled: true }; }
115
+ if (event.type !== "click" || event.button !== "left") return;
116
+ if (event.y === 0 && event.x >= width - 3) { finish(undefined); return { handled: true }; }
117
+ const row = event.y - layout.top;
118
+ // Collapsed layouts draw the list in the only pane, so any column inside the frame hits a row.
119
+ const inList = layout.leftWidth ? event.x < layout.leftWidth + 3 : event.x < width;
120
+ const index = first + row;
121
+ if (row < 0 || row >= layout.rows || !inList || !rows[index]) return;
122
+ // First click selects (details follow); clicking the selected row again resumes it.
123
+ if (index === selected) finish(rows[index]!.path);
124
+ else move(index);
125
+ return { handled: true };
62
126
  },
63
127
  render(available: number): string[] {
64
- width = Math.max(0, available);
65
- const fit = (line: string) => truncateToWidth(line, width);
128
+ width = Math.max(12, available);
129
+ listRows = contentRows(CHROME, 4);
66
130
  selected = Math.max(0, Math.min(selected, rows.length - 1));
67
- const lines = [
68
- fit(theme.fg("accent", `╭─ SESSIONS · ${rows.length}/${sessions.length} ─`)),
69
- fit(theme.fg("muted", `│ Search: ${cleanText(query, 100)}${query ? "" : "(type to filter)"}`))
131
+ reveal();
132
+ const leftWidth = sidebarWidth(width, 22, 40);
133
+ const bodyWidth = leftWidth ? width - leftWidth - 6 : width - 4;
134
+ const listWidth = leftWidth || bodyWidth;
135
+ const list = rows.slice(first, first + listRows).map((session, offset) => {
136
+ const active = first + offset === selected;
137
+ const when = age(session.modified);
138
+ const title = truncateToWidth(cleanText(sessionDisplayName(session.name, session.id), 200), Math.max(1, listWidth - 2 - (when ? when.length + 1 : 0)));
139
+ return fg(active ? "accent" : "muted", (active ? "▌" : " ") + " " + title) + (when ? fg("dim", " " + when) : "");
140
+ });
141
+ if (!rows.length) list.push(fg("dim", "No matching sessions"));
142
+ const session = rows[selected];
143
+ if (session && leftWidth) load(session);
144
+ const right = leftWidth ? (session ? describe(session, bodyWidth, listRows) : []) : list;
145
+ const footer = [
146
+ fg("muted", `Search: ${cleanText(query, 100)}${query ? "" : fg("dim", "(type to filter)")}`),
147
+ fg("dim", "Type to search · ↑↓/PgUp/PgDn select · Enter/click again resume · Esc cancel")
70
148
  ];
71
- for (let i = first; i < Math.min(rows.length, first + pageSize()); i++) {
72
- const session = rows[i]!;
73
- const title = cleanText(sessionDisplayName(session.name, session.id), 55);
74
- const prompt = cleanText(session.firstMessage, 90);
75
- const when = Number.isNaN(session.modified.getTime()) ? "" : session.modified.toISOString().slice(0, 10);
76
- lines.push(fit(theme.fg(i === selected ? "accent" : "muted", `│ ${i === selected ? "❯" : " "} ${title} · ${when}`)));
77
- lines.push(fit(theme.fg("dim", `│ ${prompt || "No first prompt"}`)));
78
- }
79
- if (!rows.length) lines.push(fit(theme.fg("dim", "│ No matching sessions")));
80
- lines.push(fit(theme.fg("dim", "╰─ Type to search · ↑↓/PgUp/PgDn select · Enter resume · Esc cancel")));
81
- return lines;
149
+ const split = splitFrame(theme, width, `${withIcon("session", "SESSIONS")} · ${rows.length}/${sessions.length}`, leftWidth ? list : [], right, footer, listRows, leftWidth);
150
+ layout = split.layout;
151
+ return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
82
152
  }
83
153
  };
84
- });
154
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
85
155
  }
@@ -1,6 +1,7 @@
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
3
  import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
4
+ import { ICON_SETS } from "./icons.ts";
4
5
  import { GOAL_ROUND_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
5
6
 
6
7
  const LABELS: Record<FooterField, string> = {
@@ -34,7 +35,7 @@ export async function openJarSettings(
34
35
  let width = 64;
35
36
  const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
36
37
  name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
37
- const fields = () => page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 5 : 6;
38
+ const fields = () => page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 5 : 7;
38
39
  const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
39
40
  const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - 9));
40
41
  const firstVisible = () => Math.min(Math.max(0, selected - pageSize() + 1), Math.max(0, fields() - pageSize()));
@@ -61,6 +62,7 @@ export async function openJarSettings(
61
62
  else if (index === 3) update({ ...state, mascot: !state.mascot });
62
63
  else if (index === 4) update({ ...state, suggestions: !state.suggestions });
63
64
  else if (index === 5) update({ ...state, ui: !state.ui });
65
+ else if (index === 6) update({ ...state, icons: ICON_SETS[(ICON_SETS.indexOf(state.icons) + 1) % ICON_SETS.length]! });
64
66
  tui.requestRender();
65
67
  };
66
68
  const row = (index: number, label: string, value: string, enabled: boolean) => {
@@ -109,12 +111,8 @@ export async function openJarSettings(
109
111
  render(available: number): string[] {
110
112
  width = Math.max(8, available);
111
113
  const state = current();
112
- const fit = (text: string) => truncateToWidth(text, width);
113
- const line = (text: string) => {
114
- const inner = Math.max(0, width - 5);
115
- const content = truncateToWidth(text, inner);
116
- return fit(theme.fg("dim", "│ ") + content + " ".repeat(Math.max(0, inner - visibleWidth(content))) + theme.fg("dim", " │"));
117
- };
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", " │");
118
116
  const title = " pi-jar · settings ";
119
117
  const top = theme.fg("accent", "╭─" + truncateToWidth(title, Math.max(0, width - 6))
120
118
  + "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
@@ -137,17 +135,18 @@ export async function openJarSettings(
137
135
  row(2, "Rounded composer", state.composer ? "ON" : "OFF", state.composer),
138
136
  row(3, "Ember mascot", state.mascot ? "ON" : "OFF", state.mascot),
139
137
  row(4, "Next-prompt suggestions", state.suggestions ? "ON" : "OFF", state.suggestions),
140
- row(5, "Pi-jar UI", state.ui ? "ON" : "OFF", state.ui)
138
+ row(5, "Pi-jar UI", state.ui ? "ON" : "OFF", state.ui),
139
+ row(6, "Icons (nerd needs a Nerd Font)", state.icons.toUpperCase(), state.icons !== "ascii")
141
140
  ];
142
141
  const start = firstVisible();
143
142
  const rows = allRows.slice(start, start + pageSize());
144
- return [fit(top), tabs, divider,
143
+ return [top, tabs, divider,
145
144
  line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
146
145
  line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
147
146
  : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
148
- ...rows.map(fit),
147
+ ...rows,
149
148
  line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
150
- fit(theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯"))];
149
+ theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
151
150
  }
152
151
  };
153
152
  });
package/src/settings.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, mkdirSync, renameSync, unlinkSync, writeFileS
2
2
  import { join } from "node:path";
3
3
  import { ACCENT_NAMES } from "./accent.ts";
4
4
  import { DEFAULT_FOOTER_SETTINGS, FOOTER_FIELDS, loadFooterSettings, type FooterSettings } from "./footer-settings.ts";
5
+ import { ICON_SETS, type IconSet } from "./icons.ts";
5
6
 
6
7
  export type JarAccent = "follow" | "default" | (typeof ACCENT_NAMES)[number];
7
8
  export interface JarVisualSettings {
@@ -20,6 +21,8 @@ export interface JarVisualSettings {
20
21
  advisor: boolean;
21
22
  /** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
22
23
  advisorGates: boolean;
24
+ /** Glyph set for footer, composer and views: unicode, Nerd Font icons, or plain ascii. */
25
+ icons: IconSet;
23
26
  footer: FooterSettings;
24
27
  }
25
28
 
@@ -27,7 +30,7 @@ export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
27
30
 
28
31
  export const SETTINGS_FILE = "pi-jar-settings.json";
29
32
  export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
30
- return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, footer: { ...footer } };
33
+ return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
31
34
  }
32
35
 
33
36
  /** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
@@ -59,6 +62,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
59
62
  ? value.goalRounds : fallback.goalRounds,
60
63
  advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
61
64
  advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
65
+ icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
62
66
  footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
63
67
  typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
64
68
  };
package/src/shells.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Key, matchesKey, Text, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import { safeLine } from "./diff-view.ts";
6
6
  import { cleanText } from "./status.ts";
7
- import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
8
7
 
9
8
  export const SHELL_TOOL = "jar_shell";
10
9
  export const SHELL_MESSAGE = "pi-jar.shell";
@@ -54,10 +53,11 @@ export class ShellManager {
54
53
  constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
55
54
 
56
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. */
57
57
  summaries(): Array<Omit<ShellJob, "lines">> {
58
58
  return [...this.jobs.values()].map(({ job }) => {
59
59
  const { lines: _lines, ...summary } = job;
60
- return { ...summary };
60
+ return summary;
61
61
  });
62
62
  }
63
63
  get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
@@ -188,13 +188,14 @@ export class ShellManager {
188
188
  }
189
189
 
190
190
  type ShellMeta = Omit<ShellJob, "lines">;
191
- const elapsed = (job: ShellMeta | ShellJob, now = Date.now()) => {
192
- const seconds = Math.max(0, Math.round(((job.endedAt ?? now) - job.startedAt) / 1000));
191
+ /** `12s` / `3m 4s` since `startedAt`, up to `endedAt` once finished. */
192
+ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Date.now()) => {
193
+ const seconds = Math.max(0, Math.round(((span.endedAt ?? now) - span.startedAt) / 1000));
193
194
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
194
195
  };
195
196
  export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
196
197
  : job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
197
- const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
198
+ export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
198
199
  const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
199
200
  let size = 0;
200
201
  const kept: string[] = [];
@@ -295,80 +296,3 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
295
296
  }
296
297
  });
297
298
  }
298
-
299
- const SHELL_ACTIONS = [
300
- { key: "x", label: "Kill the selected shell" },
301
- { key: "f", label: "Follow the latest output" }
302
- ] as const;
303
-
304
- /** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
305
- export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
306
- if (!ctx.hasUI || ctx.mode !== "tui") return;
307
- if (!manager.summaries().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
308
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
309
- let selected = 0;
310
- let follow = true;
311
- let scroll = 0;
312
- let rows = 0;
313
- let width = 80;
314
- let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
315
- const fg = (color: string, text: string) => theme.fg(color as never, text);
316
- const previous = manager.onChange;
317
- const timer = setInterval(() => tui.requestRender(), 1000);
318
- timer.unref?.();
319
- manager.onChange = () => { previous?.(); tui.requestRender(); };
320
- const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
321
- const component = {
322
- invalidate() {},
323
- handleInput(data: string) {
324
- const jobs = manager.summaries();
325
- if (matchesKey(data, Key.escape) || data === "q") return close();
326
- if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
327
- else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
328
- else if (data === "x" || data === "K") { const job = jobs[selected]; if (job) { try { manager.kill(job.id); } catch (error) { ctx.ui.notify("pi-jar: " + String(error), "error"); } } }
329
- else if (matchesKey(data, Key.pageUp)) { follow = false; scroll = Math.max(0, scroll - Math.max(1, rows - 2)); }
330
- else if (matchesKey(data, Key.pageDown)) scroll += Math.max(1, rows - 2);
331
- else if (data === "f" || data === "G") follow = true;
332
- tui.requestRender();
333
- },
334
- handleMouse(event: TuiMouseEvent) {
335
- const row = event.y - layout.top;
336
- if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
337
- if (event.type !== "click" || event.button !== "left") return;
338
- if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
339
- if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.summaries().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
340
- const action = SHELL_ACTIONS[event.y - layout.footerTop];
341
- if (action) { component.handleInput(action.key); return { handled: true }; }
342
- },
343
- render(available: number): string[] {
344
- width = Math.max(24, available);
345
- rows = contentRows(6 + SHELL_ACTIONS.length, 6);
346
- const jobs = manager.summaries();
347
- selected = Math.min(selected, Math.max(0, jobs.length - 1));
348
- const job = jobs[selected];
349
- const listWidth = sidebarWidth(width, 20, 34);
350
- const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
351
- const output = job ? manager.output(job.id, 400) : [];
352
- const maxScroll = Math.max(0, output.length - rows);
353
- scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
354
- if (scroll >= maxScroll) follow = true;
355
- const list = jobs.slice(0, rows).map((item, index) => {
356
- const color = item.status === "running" ? "accent" : item.status === "exited" && item.exitCode === 0 ? "success" : item.status === "killed" ? "dim" : "error";
357
- const glyph = item.status === "running" ? "●" : item.status === "exited" && item.exitCode === 0 ? "✔" : item.status === "killed" ? "■" : "✖";
358
- return fg(index === selected ? "accent" : "muted", (index === selected ? "▌" : " ")) + fg(color, glyph + " ") + fg(index === selected ? "accent" : "muted", truncateToWidth(`${item.id} ${item.name}`, Math.max(4, listWidth - 4)));
359
- });
360
- const body = output.slice(scroll, scroll + rows).map((line) => fg("muted", truncateToWidth(safeLine(line), bodyWidth)));
361
- if (job && !output.length) body.push(fg("dim", "(no output yet)"));
362
- const title = `⚙ SHELLS · ${manager.running()} running` + (job ? ` · ${describe(job)}` : "");
363
- const footer = [
364
- ...optionList(theme, SHELL_ACTIONS.map((item) => item.label), -1, SHELL_ACTIONS.map((item) => item.key)),
365
- fg("dim", (job ? "$ " + truncateToWidth(cleanText(job.command, 400), Math.max(8, width - 60)) + " " : "") + "↑↓ shell · PgUp/PgDn scroll · Esc close")
366
- ];
367
- const split = splitFrame(theme, width, title, list, body, footer, rows, listWidth);
368
- layout = split.layout;
369
- return split.lines;
370
- }
371
- };
372
- return component;
373
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
374
- }