pi-jar 0.1.2 → 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.
package/src/footer.ts CHANGED
@@ -3,6 +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
7
  import type { RoleStatus } from "./roles.ts";
7
8
  import { ACTIVE_STATES, cleanText, type JarStatus } from "./status.ts";
8
9
  import type { Quota } from "./quota.ts";
@@ -18,8 +19,10 @@ export interface FooterView extends JarStatus {
18
19
  settings?: FooterSettings;
19
20
  context: string;
20
21
  goal?: string;
21
- /** pi-jar's own short indicators (changes to review, running shells), shown before extras. */
22
+ /** pi-jar's own short indicators (changes to review), shown before extras. */
22
23
  chips?: readonly string[];
24
+ /** Live subagents and background shells, one clickable row each. */
25
+ activity?: readonly FooterActivity[];
23
26
  memory?: string;
24
27
  cost?: string;
25
28
  quota?: Quota;
@@ -30,6 +33,49 @@ export interface FooterView extends JarStatus {
30
33
  motionBudget?: number;
31
34
  }
32
35
 
36
+ export type ActivityState = "queued" | "running" | "done" | "failed" | "stopped";
37
+ export interface FooterActivity {
38
+ kind: "subagent" | "shell";
39
+ id: string;
40
+ name: string;
41
+ state: ActivityState;
42
+ /** What it is doing now: the current tool, or the shell command. */
43
+ detail?: string;
44
+ /** Right-aligned counters, e.g. `3 tools · 12s`. */
45
+ stats?: string;
46
+ }
47
+
48
+ /** A clickable footer region, in the footer component's local cells. */
49
+ export type FooterTarget = { kind: "subagent" | "shell"; id: string } | { kind: "activity" } | { kind: "session" };
50
+ export interface FooterHit { y: number; x0: number; x1: number; target: FooterTarget }
51
+
52
+ const MAX_ACTIVITY_ROWS = 3;
53
+ const THINKING_ICONS: Record<string, IconKey> = {
54
+ minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
55
+ };
56
+
57
+ /** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
58
+ const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
59
+
60
+ function activityGlyph(state: ActivityState): { glyph: string; color: Color } {
61
+ if (state === "running") return { glyph: icon("running"), color: "accent" };
62
+ if (state === "queued") return { glyph: icon("pending"), color: "dim" };
63
+ if (state === "done") return { glyph: icon("success"), color: "success" };
64
+ if (state === "failed") return { glyph: icon("error"), color: "error" };
65
+ return { glyph: icon("stopped"), color: "dim" };
66
+ }
67
+
68
+ function activityRow(item: FooterActivity, width: number, theme: FooterTheme): string {
69
+ const { glyph, color } = activityGlyph(item.state);
70
+ const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
71
+ withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
72
+ const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
73
+ const room = width - visibleWidth(head) - visibleWidth(stats) - 4;
74
+ const detail = item.detail && room >= 6 ? " " + theme.fg("dim", truncateToWidth(cleanText(item.detail, 200), room, "…")) : "";
75
+ const gap = " ".repeat(Math.max(1, width - visibleWidth(head) - visibleWidth(detail) - visibleWidth(stats)));
76
+ return truncateToWidth(head + detail + (stats ? gap + stats : ""), width);
77
+ }
78
+
33
79
  function fitCwd(cwd: string, width: number): string {
34
80
  if (visibleWidth(cwd) <= width) return cwd;
35
81
  return "…" + sliceByColumn(cwd, visibleWidth(cwd) - width + 1, width - 1);
@@ -45,33 +91,50 @@ function roleColor(role: RoleStatus): Color {
45
91
 
46
92
  /** Every line is bounded in terminal cells; critical role failure takes precedence. */
47
93
  export function renderFooter(view: FooterView, width: number, theme: FooterTheme): string[] {
94
+ return renderFooterLayout(view, width, theme).lines;
95
+ }
96
+
97
+ /** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
98
+ export function renderFooterLayout(view: FooterView, width: number, theme: FooterTheme): { lines: string[]; hits: FooterHit[] } {
99
+ const hits: FooterHit[] = [];
100
+ const lines = footerLines(view, width, theme, hits);
101
+ return { lines, hits };
102
+ }
103
+
104
+ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
48
105
  if (width <= 0) return [];
49
106
  const show = view.settings ?? DEFAULT_FOOTER_SETTINGS;
50
- if (!view.goal && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
107
+ const activity = view.activity ?? [];
108
+ if (!view.goal && !activity.length && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
51
109
  // Keep tiny terminals unframed so context and urgent role status stay readable.
52
110
  const framed = width >= 32;
53
111
  const contentWidth = framed ? width - 4 : width;
112
+ // Local cell offsets of content inside the frame, for click targets.
113
+ const offsetX = framed ? 2 : 0;
114
+ const offsetY = framed ? 1 : 0;
54
115
  const roles = show.roles ? [...view.roles].sort((a, b) => Number(b.state === "failed") - Number(a.state === "failed")) : [];
55
116
  const primary = roles.find((role) => role.state === "failed") ?? roles.find((role) => ACTIVE_STATES.has(role.state));
56
117
  const model = cleanText(view.model, 24) || "no-model";
57
- const context = cleanText(view.context, 12) || "ctx ?";
118
+ const context = metric("context", "ctx", cleanText(view.context, 12) || "ctx ?");
58
119
  const goal = view.goal ? cleanText(view.goal, 240) : "";
59
120
  const quota = view.quota;
60
- const windows = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
61
- const cost = view.cost ? cleanText(view.cost, 20) : undefined;
121
+ const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
122
+ const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
123
+ const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
124
+ const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
62
125
  const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
63
126
  const effortColors = {
64
127
  off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
65
128
  high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
66
129
  } as const;
67
- const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], view.effort) : "";
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) : "";
68
131
  const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
69
132
  const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
70
133
  // At narrow widths reserve the right-hand slot for critical/active status, then context.
71
- const modelLabel = show.model ? theme.fg("muted", model) : "";
134
+ const modelLabel = show.model ? theme.fg("muted", withIcon("model", model)) : "";
72
135
  const left = [prefix, modelLabel, effort].filter(Boolean).join(theme.fg("dim", " · "));
73
136
  // Context is never dropped; optional metrics move to a second line on medium widths.
74
- const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && view.memory ? [view.memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
137
+ const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
75
138
  let right = theme.fg("dim", metrics.join(" · "));
76
139
  if (contentWidth < 52 && attention && contentWidth >= 26) {
77
140
  const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
@@ -87,18 +150,24 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
87
150
  if (room >= 3 && beforeEffort) return truncateToWidth(beforeEffort, room) + theme.fg("dim", " · ") + effort;
88
151
  return truncateToWidth(beforeEffort || effort, availableLeft);
89
152
  })();
90
- const name = show.sessionName && view.sessionName ? cleanText(view.sessionName, 128) : "";
153
+ const name = show.sessionName ? cleanText(view.sessionName || "sessions", 128) : "";
91
154
  const rawCwd = show.cwd && view.cwd ? cleanText(view.cwd, 256) : "";
92
155
  const home = homedir();
93
156
  const cwd = home && rawCwd.startsWith(home + sep) ? `~${rawCwd.slice(home.length)}` : rawCwd;
94
157
  let namedLeft = fittedLeft;
95
158
  let space = availableLeft - visibleWidth(namedLeft);
96
- if (cwd && space >= 7) {
97
- const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3);
98
- namedLeft += theme.fg("dim", " · ") + theme.fg("muted", fitCwd(cwd, budget));
159
+ const folder = icon("folder") ? icon("folder") + " " : "";
160
+ if (cwd && space >= 7 + folder.length) {
161
+ const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3 - folder.length);
162
+ namedLeft += theme.fg("dim", " · ") + theme.fg("muted", folder + fitCwd(cwd, budget));
99
163
  space = availableLeft - visibleWidth(namedLeft);
100
164
  }
101
- if (name && space >= 7) namedLeft += theme.fg("dim", " · ") + theme.fg("muted", truncateToWidth(name, space - 3, "…"));
165
+ if (name && space >= 7) {
166
+ const start = visibleWidth(namedLeft) + 3;
167
+ const label = truncateToWidth(withIcon("session", name), space - 3, "…");
168
+ namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
169
+ hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
170
+ }
102
171
  const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(namedLeft) - visibleWidth(right)));
103
172
  const lines = [truncateToWidth(namedLeft + gap + right, contentWidth)];
104
173
  if (goal) {
@@ -107,8 +176,16 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
107
176
  lines.push(truncateToWidth(goalPrefix + theme.fg("muted", truncateToWidth(goal, room, "…")), contentWidth));
108
177
  }
109
178
  if (contentWidth < 52) {
110
- const small = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])];
179
+ const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
111
180
  if (small.length && contentWidth >= 26) lines.push(truncateToWidth(theme.fg("dim", small.join(" · ")), contentWidth));
181
+ // Too narrow for rows: one clickable count of live work.
182
+ const agents = activity.filter((item) => item.kind === "subagent" && (item.state === "running" || item.state === "queued")).length;
183
+ const shells = activity.filter((item) => item.kind === "shell" && item.state === "running").length;
184
+ const counts = [agents ? withIcon("subagents", `${agents}`) : "", shells ? withIcon("shell", `${shells}`) : ""].filter(Boolean).join(" ");
185
+ if (counts) {
186
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
187
+ lines.push(truncateToWidth(theme.fg("accent", counts), contentWidth));
188
+ }
112
189
  return framed ? frameFooter(lines, width, view, theme) : lines;
113
190
  }
114
191
 
@@ -123,19 +200,29 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
123
200
  const remaining = Math.max(0, view.extras.length - (contentWidth >= 100 ? 2 : 1));
124
201
  const extras = show.extras ? view.extras.slice(0, contentWidth >= 100 ? 2 : 1).map((status) => theme.fg("muted", status)) : [];
125
202
  if (show.extras && remaining) extras.push(theme.fg("dim", `+${remaining}`));
126
- if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", `git ${cleanText(view.branch, 28)}`));
203
+ if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", withIcon("branch", cleanText(view.branch, 28))));
127
204
  const chips = (view.chips ?? []).map((chip) => theme.fg("warning", cleanText(chip, 32)));
128
205
  const joined = [...segments, ...chips, ...extras].join(theme.fg("dim", " · "));
129
206
  if (joined) lines.push(truncateToWidth(joined, contentWidth));
130
- if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && view.memory))) {
131
- const summary = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])].join(" · ");
207
+ if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && memory))) {
208
+ const summary = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])].join(" · ");
132
209
  if (summary) lines.push(truncateToWidth(theme.fg("dim", summary), contentWidth));
133
210
  }
211
+ const shown = activity.length > MAX_ACTIVITY_ROWS ? activity.slice(0, MAX_ACTIVITY_ROWS - 1) : activity;
212
+ for (const item of shown) {
213
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: item.kind, id: item.id } });
214
+ lines.push(activityRow(item, contentWidth, theme));
215
+ }
216
+ if (shown.length < activity.length) {
217
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
218
+ lines.push(truncateToWidth(theme.fg("dim", ` +${activity.length - shown.length} more · ctrl+alt+a to manage`), contentWidth));
219
+ }
134
220
  return framed ? frameFooter(lines, width, view, theme) : lines;
135
221
  }
136
222
 
137
223
  function frameFooter(lines: string[], width: number, view: FooterView, theme: FooterTheme): string[] {
138
- const active = (view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state));
224
+ const active = ((view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state)))
225
+ || !!view.activity?.some((item) => item.state === "running");
139
226
  const pulse = active && view.animations ? ["◇", "◈", "◆", "◈"][view.frame % 4] : "◇";
140
227
  const top = theme.fg("dim", "╭─") + theme.fg(active ? "accent" : "dim", pulse)
141
228
  + theme.fg("dim", "─".repeat(width - 4) + "╮");
package/src/icons.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Glyph sets for pi-jar chrome, modelled on omp's symbol presets:
3
+ * - `unicode`: symbols every monospace font ships (default);
4
+ * - `nerd`: Nerd Font icons (install a Nerd Font, e.g. via `brew install --cask font-jetbrains-mono-nerd-font`);
5
+ * - `ascii`: no decorative glyphs; key hints spelled out.
6
+ * Decorative keys may map to "" in `ascii`; always render through `withIcon`.
7
+ */
8
+ export const ICON_SETS = ["unicode", "nerd", "ascii"] as const;
9
+ export type IconSet = (typeof ICON_SETS)[number];
10
+
11
+ export type IconKey =
12
+ | "pi" | "model" | "folder" | "branch" | "context" | "memory" | "cost" | "quota" | "session"
13
+ | "subagents" | "agent" | "shell" | "changes" | "goal" | "plan" | "tasks" | "time" | "tools" | "roles"
14
+ | "esc" | "enter" | "tab" | "shiftEnter"
15
+ | "success" | "error" | "running" | "pending" | "stopped" | "warning"
16
+ | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax";
17
+
18
+ const UNICODE: Record<IconKey, string> = {
19
+ pi: "π", model: "⬢", folder: "⌂", branch: "⎇", context: "◫", memory: "▤", cost: "", quota: "◔", session: "◈",
20
+ subagents: "⧉", agent: "◉", shell: "⚙", changes: "±", goal: "◎", plan: "◆", tasks: "☰", time: "◷", tools: "⚒", roles: "⇄",
21
+ esc: "⎋", enter: "⏎", tab: "⇥", shiftEnter: "⇧⏎",
22
+ success: "✔", error: "✖", running: "●", pending: "○", stopped: "■", warning: "⚠",
23
+ thinkingMinimal: "○", thinkingLow: "◔", thinkingMedium: "◑", thinkingHigh: "◒", thinkingXhigh: "◕", thinkingMax: "◉"
24
+ };
25
+
26
+ // Code points follow omp's nerd preset (Font Awesome / Codicons / Material Design ranges).
27
+ const NERD: Record<IconKey, string> = {
28
+ pi: "\u{f0d57}", model: "\uec19", folder: "\uf115", branch: "\uf126", context: "\ue70f", memory: "\u{f035b}",
29
+ cost: "\uf0d6", quota: "\uf0e4", session: "\u{f02da}",
30
+ subagents: "\uf0c0", agent: "\uf007", shell: "\uf120", changes: "\uf044", goal: "\uf140", plan: "\uf2d2",
31
+ tasks: "\uf0ae", time: "\uf017", tools: "\uf0ad", roles: "\u{f04e1}",
32
+ esc: "\u{f12b7}", enter: "\u{f0311}", tab: "\u{f0312}", shiftEnter: "\u{f0636}\u{f0311}",
33
+ success: "\uf00c", error: "\uf00d", running: "\uf111", pending: "\uf10c", stopped: "\uf04d", warning: "\uf071",
34
+ thinkingMinimal: "\u{f0a9e}", thinkingLow: "\u{f0a9f}", thinkingMedium: "\u{f0aa1}", thinkingHigh: "\u{f0aa3}",
35
+ thinkingXhigh: "\u{f0aa5}", thinkingMax: "\uf06d"
36
+ };
37
+
38
+ const ASCII: Record<IconKey, string> = {
39
+ pi: "", model: "", folder: "", branch: "git", context: "ctx", memory: "ram", cost: "cost", quota: "quota", session: "",
40
+ subagents: "", agent: "", shell: "$", changes: "+/-", goal: "goal", plan: "plan", tasks: "", time: "", tools: "", roles: "",
41
+ esc: "esc", enter: "enter", tab: "tab", shiftEnter: "shift+enter",
42
+ success: "ok", error: "x", running: "*", pending: "-", stopped: "#", warning: "!",
43
+ thinkingMinimal: "", thinkingLow: "", thinkingMedium: "", thinkingHigh: "", thinkingXhigh: "", thinkingMax: ""
44
+ };
45
+
46
+ const SETS: Record<IconSet, Record<IconKey, string>> = { unicode: UNICODE, nerd: NERD, ascii: ASCII };
47
+ let active: IconSet = "unicode";
48
+
49
+ /** Process-wide: renderers read the active set on every paint, so a settings change applies on the next frame. */
50
+ export function setIconSet(set: IconSet): void { active = set; }
51
+ export function iconSet(): IconSet { return active; }
52
+ export function icon(key: IconKey): string { return SETS[active][key]; }
53
+ /** `glyph text`, or just `text` when the active set has no glyph for `key`. */
54
+ export function withIcon(key: IconKey, text: string): string {
55
+ const glyph = SETS[active][key];
56
+ return glyph ? (text ? glyph + " " + text : glyph) : text;
57
+ }
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;
@@ -53,9 +53,11 @@ 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; };
59
61
  return {
60
62
  invalidate() {},
61
63
  handleInput(data: string) {
@@ -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,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,101 @@ 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
+ 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
+ };
36
96
  return {
37
97
  invalidate() {},
38
98
  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();
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(); }
49
107
  },
50
108
  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
- }
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 };
62
121
  },
63
122
  render(available: number): string[] {
64
- width = Math.max(0, available);
65
- const fit = (line: string) => truncateToWidth(line, width);
123
+ width = Math.max(12, available);
124
+ listRows = contentRows(CHROME, 4);
66
125
  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)"}`))
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")
70
143
  ];
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;
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;
82
147
  }
83
148
  };
84
- });
149
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
85
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
  };