pi-jar 0.1.1 → 0.1.3

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.
@@ -4,8 +4,21 @@ import { cleanText } from "./status.ts";
4
4
 
5
5
  export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
6
6
 
7
- const userText = (message: { content?: unknown }) => typeof message.content === "string" ? message.content
8
- : Array.isArray(message.content) ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n") : "";
7
+ const MAX_PROMPT_CHARS = 8000;
8
+ const userText = (message: { content?: unknown }): string => {
9
+ if (typeof message.content === "string") return message.content.slice(0, MAX_PROMPT_CHARS + 1);
10
+ if (!Array.isArray(message.content)) return "";
11
+ let text = "";
12
+ for (const part of message.content as Array<{ type?: string; text?: string }>) {
13
+ if (part?.type !== "text" || typeof part.text !== "string") continue;
14
+ const separator = text ? "\n" : "";
15
+ const room = MAX_PROMPT_CHARS + 1 - text.length - separator.length;
16
+ if (room <= 0) break;
17
+ text += separator + part.text.slice(0, room);
18
+ if (part.text.length > room) break;
19
+ }
20
+ return text;
21
+ };
9
22
 
10
23
  /**
11
24
  * Prompts newest first and deduplicated: every user message in this session file (all branches),
@@ -17,13 +30,17 @@ export function collectPrompts(entries: readonly unknown[], earlier: readonly {
17
30
  const add = (text: string, source: PastPrompt["source"], at?: number) => {
18
31
  const value = text.trim();
19
32
  const key = value.replace(/\s+/g, " ");
20
- if (!value || value.length > 8000 || seen.has(key)) return;
33
+ if (!value || value.length > MAX_PROMPT_CHARS || seen.has(key)) return;
21
34
  seen.add(key);
22
35
  result.push({ text: value, source, ...(at ? { at } : {}) });
23
36
  };
24
- const own = entries.filter((raw): raw is { type: string; message: { role: string; content?: unknown }; timestamp?: string } =>
25
- !!raw && typeof raw === "object" && (raw as { type?: string }).type === "message" && (raw as { message?: { role?: string } }).message?.role === "user");
26
- for (const entry of [...own].reverse()) add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
37
+ for (let index = entries.length - 1; index >= 0; index--) {
38
+ const raw = entries[index];
39
+ if (!raw || typeof raw !== "object") continue;
40
+ const entry = raw as { type?: string; message?: { role?: string; content?: unknown }; timestamp?: string };
41
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
42
+ add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
43
+ }
27
44
  for (const session of earlier) if (session.firstMessage) add(session.firstMessage, "earlier session", session.modified?.getTime());
28
45
  return result;
29
46
  }
@@ -36,9 +53,11 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
36
53
  let query = "";
37
54
  let selected = 0;
38
55
  let first = 0;
39
- let visible: PastPrompt[] = [...prompts];
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;
40
59
  const rows = 10;
41
- const filter = () => { visible = query ? fuzzyFilter([...prompts], query, (item) => item.text) : [...prompts]; selected = 0; first = 0; };
60
+ const filter = () => { visible = query ? fuzzyFilter(pool, query, (item) => item.text) : pool; selected = 0; first = 0; };
42
61
  return {
43
62
  invalidate() {},
44
63
  handleInput(data: string) {
package/src/quota.ts CHANGED
@@ -20,17 +20,27 @@ function parseQuota(value: unknown): Quota | undefined {
20
20
  return fiveHour || week ? { ...(fiveHour ? { fiveHour } : {}), ...(week ? { week } : {}) } : undefined;
21
21
  }
22
22
 
23
+ const publishedCache = new Map<string, { raw: string; expiresAt?: number; value?: Quota }>();
24
+
23
25
  /** Public statuses are authoritative only when valid and fresh. No arbitrary status prose is parsed. */
24
26
  export function publishedQuota(statuses: ReadonlyMap<string, string>, provider: string, now: number): Quota | undefined {
25
27
  const raw = statuses.get(QUOTA_PREFIX + provider);
26
28
  if (typeof raw !== "string" || raw.length > 2048) return undefined;
29
+ const cached = publishedCache.get(provider);
30
+ if (cached?.raw === raw) {
31
+ const { expiresAt, value } = cached;
32
+ return value && expiresAt != null && expiresAt > now && expiresAt <= now + QUOTA_TTL_MS ? value : undefined;
33
+ }
27
34
  try {
28
35
  const data: unknown = JSON.parse(raw);
29
- if (!data || typeof data !== "object") return undefined;
36
+ if (!data || typeof data !== "object") { publishedCache.set(provider, { raw }); return undefined; }
30
37
  const obj = data as Record<string, unknown>;
31
- if (typeof obj.expiresAt !== "number" || obj.expiresAt <= now || obj.expiresAt > now + QUOTA_TTL_MS) return undefined;
32
- return parseQuota(obj);
33
- } catch { return undefined; }
38
+ const expiresAt = obj.expiresAt;
39
+ const value = parseQuota(obj);
40
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || !value) { publishedCache.set(provider, { raw }); return undefined; }
41
+ publishedCache.set(provider, { raw, expiresAt, value });
42
+ return expiresAt > now && expiresAt <= now + QUOTA_TTL_MS ? value : undefined;
43
+ } catch { publishedCache.set(provider, { raw }); return undefined; }
34
44
  }
35
45
 
36
46
  function codexAccountId(token: string): string | undefined {
@@ -0,0 +1,24 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type SessionManagerLike = ExtensionContext["sessionManager"] & { getLeafId?: () => string | null | undefined };
4
+ const cache = new WeakMap<object, { leaf: string; branch: readonly unknown[] }>();
5
+
6
+ /**
7
+ * Pi's getBranch() walks leaf → root and reverses a new array every call. Reuse that projection
8
+ * across pi-jar features while the leaf id is unchanged; a new session entry naturally changes
9
+ * the leaf and invalidates the cache.
10
+ */
11
+ export function sessionBranch(ctx: Pick<ExtensionContext, "sessionManager">): readonly unknown[] {
12
+ const manager = ctx.sessionManager as SessionManagerLike;
13
+ const leaf = manager.getLeafId?.();
14
+ if (!leaf) return manager.getBranch();
15
+ const hit = cache.get(manager as object);
16
+ if (hit?.leaf === leaf) return hit.branch;
17
+ const branch = manager.getBranch();
18
+ cache.set(manager as object, { leaf, branch });
19
+ return branch;
20
+ }
21
+
22
+ export function clearSessionBranchCache(ctx: Pick<ExtensionContext, "sessionManager">): void {
23
+ cache.delete(ctx.sessionManager as object);
24
+ }
@@ -5,8 +5,8 @@ import { GOAL_ENTRY, GoalStore } from "./goals.ts";
5
5
  import { cleanText } from "./status.ts";
6
6
 
7
7
  export const RECENT_SESSIONS = 3;
8
- /** Larger session files are listed without goal/plan details. */
9
- const MAX_SCAN_BYTES = 8 * 1024 * 1024;
8
+ /** Welcome metadata is decorative; never parse multi-megabyte old transcripts for it. */
9
+ export const MAX_SCAN_BYTES = 512 * 1024;
10
10
 
11
11
  export interface RecentSession { path: string; title: string; modified: Date; messages: number; goal?: string; plan?: string }
12
12
 
@@ -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 { 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,65 +17,134 @@ 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;
37
+ const indexed = sessions.map((session) => ({
38
+ session,
39
+ haystack: `${session.name ?? ""} ${sessionDisplayName(session.name, session.id)} ${session.firstMessage}`.toLowerCase()
40
+ }));
41
+ const search = (query: string): SessionInfo[] => {
42
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
43
+ if (!terms.length) return sessions.slice();
44
+ const rows: SessionInfo[] = [];
45
+ for (const item of indexed) if (terms.every((term) => item.haystack.includes(term))) rows.push(item.session);
46
+ return rows;
47
+ };
17
48
  return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
18
49
  let query = initial.slice(0, 100);
19
50
  let selected = 0;
20
51
  let first = 0;
21
52
  let width = 80;
22
- const pageSize = () => Math.max(2, Math.min(10, Math.floor(((process.stdout.rows ?? 24) - 7) / 2)));
23
- const matches = () => filterSessions(sessions, query);
24
- const refresh = () => { selected = 0; first = 0; tui.requestRender(); };
53
+ let listRows = contentRows(CHROME, 4);
54
+ let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
55
+ let rows = search(query);
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(); };
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
+ const describe = (session: SessionInfo, bodyWidth: number, height: number): string[] => {
72
+ const date = session.modified;
73
+ const when = Number.isNaN(date.getTime()) ? "unknown"
74
+ : `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}` + (age(date) ? ` · ${age(date)} ago` : "");
75
+ const lines = [
76
+ fg("accent", truncateToWidth(cleanText(sessionDisplayName(session.name, session.id), 200), bodyWidth)),
77
+ fg("muted", withIcon("session", cleanText(session.id, 36).slice(0, 8))),
78
+ fg("muted", withIcon("time", when)),
79
+ fg("muted", `${session.messageCount} message${session.messageCount === 1 ? "" : "s"}`)
80
+ ];
81
+ if (session.cwd) lines.push(fg("muted", withIcon("folder", cleanText(session.cwd, 400))));
82
+ if (details) {
83
+ const loaded = cache.get(session.path);
84
+ if (!loaded) lines.push(fg("dim", "Loading details…"));
85
+ else {
86
+ for (const [key, text] of [["goal", loaded.goal], ["plan", loaded.plan]] as const) {
87
+ if (text?.trim()) lines.push(...wrapTextWithAnsi(withIcon(key, cleanText(text, 600)), Math.max(1, bodyWidth)).slice(0, 3).map((line) => fg("text", line)));
88
+ }
89
+ }
90
+ }
91
+ lines.push("", fg("dim", "First prompt"));
92
+ const prompt = cleanText(session.firstMessage, 4096);
93
+ lines.push(...(prompt ? wrapTextWithAnsi(prompt, Math.max(1, bodyWidth)).map((line) => fg("muted", line)) : [fg("dim", "No first prompt")]));
94
+ return lines.slice(0, height).map((line) => truncateToWidth(line, Math.max(0, bodyWidth)));
95
+ };
25
96
  return {
26
97
  invalidate() {},
27
98
  handleInput(data: string) {
28
- const rows = matches();
29
- if (matchesKey(data, Key.escape)) return done(undefined);
30
- if (matchesKey(data, Key.enter)) return done(rows[selected]?.path);
31
- if (matchesKey(data, Key.up)) selected = Math.max(0, selected - 1);
32
- else if (matchesKey(data, Key.down)) selected = Math.min(rows.length - 1, selected + 1);
33
- else if (matchesKey(data, Key.pageUp)) selected = Math.max(0, selected - pageSize());
34
- else if (matchesKey(data, Key.pageDown)) selected = Math.min(rows.length - 1, selected + pageSize());
35
- else if (matchesKey(data, Key.backspace) || data === "\x7f") { query = query.slice(0, -1); return refresh(); }
36
- else if (/^[\x20-\x7e]$/.test(data) && query.length < 100) { query += data; return refresh(); }
37
- first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
38
- tui.requestRender();
99
+ if (matchesKey(data, Key.escape)) return finish(undefined);
100
+ if (matchesKey(data, Key.enter)) return finish(rows[selected]?.path);
101
+ if (matchesKey(data, Key.up)) move(selected - 1);
102
+ else if (matchesKey(data, Key.down)) move(selected + 1);
103
+ else if (matchesKey(data, Key.pageUp)) move(selected - listRows);
104
+ else if (matchesKey(data, Key.pageDown)) move(selected + listRows);
105
+ else if (matchesKey(data, Key.backspace) || data === "\x7f") { query = query.slice(0, -1); refresh(); }
106
+ else if (/^[\x20-\x7e]$/.test(data) && query.length < 100) { query += data; refresh(); }
39
107
  },
40
108
  handleMouse(event: TuiMouseEvent) {
41
- if (event.type === "wheel" && event.wheelDelta) {
42
- selected = Math.max(0, Math.min(matches().length - 1, selected + Math.sign(event.wheelDelta)));
43
- first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
44
- tui.requestRender(); return { handled: true };
45
- }
46
- if (event.type !== "click" || event.button !== "left" || event.x >= width) return;
47
- const index = first + Math.floor((event.y - 2) / 2);
48
- const row = matches()[index];
49
- if (event.y >= 2 && event.y < 2 + 2 * pageSize() && row) {
50
- selected = index; done(row.path); return { handled: true };
51
- }
109
+ if (event.type === "wheel" && event.wheelDelta) { move(selected + Math.sign(event.wheelDelta)); return { handled: true }; }
110
+ if (event.type !== "click" || event.button !== "left") return;
111
+ if (event.y === 0 && event.x >= width - 3) { finish(undefined); return { handled: true }; }
112
+ const row = event.y - layout.top;
113
+ // Collapsed layouts draw the list in the only pane, so any column inside the frame hits a row.
114
+ const inList = layout.leftWidth ? event.x < layout.leftWidth + 3 : event.x < width;
115
+ const index = first + row;
116
+ if (row < 0 || row >= layout.rows || !inList || !rows[index]) return;
117
+ // First click selects (details follow); clicking the selected row again resumes it.
118
+ if (index === selected) finish(rows[index]!.path);
119
+ else move(index);
120
+ return { handled: true };
52
121
  },
53
122
  render(available: number): string[] {
54
- width = Math.max(0, available);
55
- const fit = (line: string) => truncateToWidth(line, width);
56
- const rows = matches();
123
+ width = Math.max(12, available);
124
+ listRows = contentRows(CHROME, 4);
57
125
  selected = Math.max(0, Math.min(selected, rows.length - 1));
58
- const lines = [
59
- fit(theme.fg("accent", `╭─ SESSIONS · ${rows.length}/${sessions.length} ─`)),
60
- fit(theme.fg("muted", `│ Search: ${cleanText(query, 100)}${query ? "" : "(type to filter)"}`))
126
+ reveal();
127
+ const leftWidth = sidebarWidth(width, 22, 40);
128
+ const bodyWidth = leftWidth ? width - leftWidth - 6 : width - 4;
129
+ const listWidth = leftWidth || bodyWidth;
130
+ const list = rows.slice(first, first + listRows).map((session, offset) => {
131
+ const active = first + offset === selected;
132
+ const when = age(session.modified);
133
+ const title = truncateToWidth(cleanText(sessionDisplayName(session.name, session.id), 200), Math.max(1, listWidth - 2 - (when ? when.length + 1 : 0)));
134
+ return fg(active ? "accent" : "muted", (active ? "▌" : " ") + " " + title) + (when ? fg("dim", " " + when) : "");
135
+ });
136
+ if (!rows.length) list.push(fg("dim", "No matching sessions"));
137
+ const session = rows[selected];
138
+ if (session && leftWidth) load(session);
139
+ const right = leftWidth ? (session ? describe(session, bodyWidth, listRows) : []) : list;
140
+ const footer = [
141
+ fg("muted", `Search: ${cleanText(query, 100)}${query ? "" : fg("dim", "(type to filter)")}`),
142
+ fg("dim", "Type to search · ↑↓/PgUp/PgDn select · Enter/click again resume · Esc cancel")
61
143
  ];
62
- for (let i = first; i < Math.min(rows.length, first + pageSize()); i++) {
63
- const session = rows[i]!;
64
- const title = cleanText(sessionDisplayName(session.name, session.id), 55);
65
- const prompt = cleanText(session.firstMessage, 90);
66
- const when = Number.isNaN(session.modified.getTime()) ? "" : session.modified.toISOString().slice(0, 10);
67
- lines.push(fit(theme.fg(i === selected ? "accent" : "muted", `│ ${i === selected ? "❯" : " "} ${title} · ${when}`)));
68
- lines.push(fit(theme.fg("dim", `│ ${prompt || "No first prompt"}`)));
69
- }
70
- if (!rows.length) lines.push(fit(theme.fg("dim", "│ No matching sessions")));
71
- lines.push(fit(theme.fg("dim", "╰─ Type to search · ↑↓/PgUp/PgDn select · Enter resume · Esc cancel")));
72
- return lines;
144
+ const split = splitFrame(theme, width, `${withIcon("session", "SESSIONS")} · ${rows.length}/${sessions.length}`, leftWidth ? list : [], right, footer, listRows, leftWidth);
145
+ layout = split.layout;
146
+ return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
73
147
  }
74
148
  };
75
- });
149
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
76
150
  }
@@ -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) => {
@@ -137,7 +139,8 @@ export async function openJarSettings(
137
139
  row(2, "Rounded composer", state.composer ? "ON" : "OFF", state.composer),
138
140
  row(3, "Ember mascot", state.mascot ? "ON" : "OFF", state.mascot),
139
141
  row(4, "Next-prompt suggestions", state.suggestions ? "ON" : "OFF", state.suggestions),
140
- row(5, "Pi-jar UI", state.ui ? "ON" : "OFF", state.ui)
142
+ row(5, "Pi-jar UI", state.ui ? "ON" : "OFF", state.ui),
143
+ row(6, "Icons (nerd needs a Nerd Font)", state.icons.toUpperCase(), state.icons !== "ascii")
141
144
  ];
142
145
  const start = firstVisible();
143
146
  const rows = allRows.slice(start, start + pageSize());
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
  };