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/README.md +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +141 -36
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/composer.ts +30 -9
- package/src/delegate.ts +150 -15
- package/src/footer.ts +105 -18
- package/src/icons.ts +57 -0
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +4 -2
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +106 -41
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +6 -83
- package/src/status.ts +2 -0
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
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
|
|
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
|
-
|
|
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
|
|
61
|
-
const
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
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 &&
|
|
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",
|
|
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 &&
|
|
131
|
-
const summary = [...(show.memory &&
|
|
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;
|
package/src/prompt-search.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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) {
|
package/src/session-gallery.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
if (matchesKey(data, Key.enter)) return
|
|
41
|
-
if (matchesKey(data, Key.up))
|
|
42
|
-
else if (matchesKey(data, Key.down))
|
|
43
|
-
else if (matchesKey(data, Key.pageUp))
|
|
44
|
-
else if (matchesKey(data, Key.pageDown))
|
|
45
|
-
else if (matchesKey(data, Key.backspace) || data === "\x7f") { query = query.slice(0, -1);
|
|
46
|
-
else if (/^[\x20-\x7e]$/.test(data) && query.length < 100) { query += data;
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const index = first +
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
65
|
-
|
|
123
|
+
width = Math.max(12, available);
|
|
124
|
+
listRows = contentRows(CHROME, 4);
|
|
66
125
|
selected = Math.max(0, Math.min(selected, rows.length - 1));
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -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 :
|
|
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
|
};
|