pi-jar 0.2.4 → 0.2.6

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 CHANGED
@@ -64,7 +64,8 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
64
64
  | `/jar` · `/jar settings` | Visual and workflow preferences. |
65
65
  | `/jar status` | One-line status summary. |
66
66
  | `/jar perf` | Long-session diagnostics on demand: branch entries, context sampling, parent and child RSS, retained/hibernated subagents and recovery worktrees, render requests vs. frames, shell output, discussion and quota cache. |
67
- | `/jar tasks [list\|add\|done\|open\|edit\|delete]` | Human view/editor for the agent's checklist. |
67
+ | `/jar tasks [list\|add\|done\|open\|edit\|delete]` | Searchable, scrollable checklist; ←/→ folds/unfolds subtasks. |
68
+ | `/jar tasks collapse\|expand\|toggle` · `/jar tasks scroll N` | Collapse/expand the compact task widget or scroll it by N rows. |
68
69
  | `/jar history` | Read-only conversation timeline for the active branch. |
69
70
  | `/diff` | Review, accept or revert the files the agent changed. |
70
71
  | `/jar activity` · `/jar shells` | Subagents and background shells: live transcript/output, stop or kill. |
@@ -90,6 +91,12 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
90
91
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd> | Search earlier prompts into the composer |
91
92
  | <kbd>Tab</kbd> / <kbd>→</kbd> in an empty composer | Accept the dim suggestion into the input (it is not sent) |
92
93
 
94
+ ## Popup navigation
95
+
96
+ Pi-jar popups stay within the terminal viewport, including after resizing. Model, fallback, alias, theme and profile pickers filter as you type; use ↑/↓, PgUp/PgDn or the mouse wheel to move and Enter to choose. In Roles and Tasks, press `/` to search the full list, Enter to apply, and Esc while searching to clear it. Other pi-jar views support `/` to filter popup content (History and Changes retain their own search). Oversized popup chrome scrolls with the wheel or Ctrl+PgUp/PgDn.
97
+
98
+ The task widget starts collapsed to a one-line summary. In fullscreen, click its heading to expand/collapse and wheel-scroll the expanded list. It uses at most a quarter of the terminal height. In regular mode use `/jar tasks expand`, `/jar tasks collapse`, or `/jar tasks scroll N`; `/jar tasks` opens the full editor, where ←/→ folds/unfolds a parent's subtasks.
99
+
93
100
  ## Profiles
94
101
 
95
102
  Profiles keep separate themes, appearance settings, and model-role configurations. **Default** preserves your existing setup.
@@ -10,7 +10,8 @@ import type { DelegateController } from "../src/delegate.ts";
10
10
  import { ComposerStyle } from "../src/composer.ts";
11
11
  import { installCompactBuiltinTools } from "../src/compact-tools.ts";
12
12
  import { WELCOME_INTERVAL_MS } from "../src/animations.ts";
13
- import { promptText } from "../src/dialogs.ts";
13
+ import { promptText, selectPopup } from "../src/dialogs.ts";
14
+ import { taskWidget, type TaskWidgetState } from "../src/task-widget.ts";
14
15
  import { renderFooterLayout, type ActivityState, type FooterActivity, type FooterHit, type FooterTarget } from "../src/footer.ts";
15
16
  import { openJarHistory } from "../src/history-ui.ts";
16
17
  import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
@@ -35,8 +36,8 @@ import { registerSuggestions, SuggestionState } from "../src/suggest.ts";
35
36
  import { createDemoRoles } from "../src/roles.ts";
36
37
  import { ACTIVE_STATES, cleanText, collectStatuses, type JarRole } from "../src/status.ts";
37
38
  import { manageTasks } from "../src/tasks-ui.ts";
38
- import { registerTaskTool, todoRow } from "../src/task-tool.ts";
39
- import { TASK_ENTRY, TodoStore, todoProgress, todoTotals } from "../src/tasks.ts";
39
+ import { registerTaskTool } from "../src/task-tool.ts";
40
+ import { TASK_ENTRY, TodoStore, todoTotals } from "../src/tasks.ts";
40
41
  import { formatCost, sessionCost } from "../src/usage.ts";
41
42
  import { branchCalls, compactForBudget, CONTEXT_CONTINUATION, ContextBudgetGuard, providerCall, resumeAfterBudget } from "../src/context-budget.ts";
42
43
  import { WorkingState } from "../src/working.ts";
@@ -168,6 +169,7 @@ export default function piJar(pi: ExtensionAPI): void {
168
169
 
169
170
  // A finished list stays visible (all struck through) until the user's next prompt, like Claude.
170
171
  let todosAcknowledged = false;
172
+ const taskWidgetState: TaskWidgetState = { expanded: false, scroll: undefined };
171
173
  const updateTaskWidget = (ctx: ExtensionContext) => {
172
174
  if (!ctx.hasUI || ctx.mode !== "tui") return;
173
175
  const items = todos?.all() ?? [];
@@ -176,36 +178,8 @@ export default function piJar(pi: ExtensionAPI): void {
176
178
  const visible = enabled && items.length > 0 && (open.length > 0 || !todosAcknowledged);
177
179
  try {
178
180
  // Rows are rebuilt only when the list changes (every change reinstalls the widget), the width or the theme.
179
- ctx.ui.setWidget("pi-jar.todos", !visible ? undefined : (_tui, theme) => {
180
- let memo: { width: number; sample: string; lines: string[] } | undefined;
181
- return {
182
- invalidate() { memo = undefined; },
183
- render(width: number) {
184
- const colors = ctx.ui.theme ?? theme;
185
- const sample = colors.fg("accent", "·") + colors.fg("dim", "·");
186
- if (memo && memo.width === width && memo.sample === sample) return memo.lines.slice();
187
- const all = todos?.all() ?? [];
188
- const { done, total } = todoTotals(all);
189
- const parents = new Set<string>();
190
- for (const item of all) if (item.parentId) parents.add(item.parentId);
191
- // Show a window of up to 8 rows that keeps the running leaf (or next open one) in view.
192
- const running = all.findIndex((item) => item.status === "in_progress" && !parents.has(item.id));
193
- const focus = Math.max(0, running >= 0 ? running : all.findIndex((item) => !item.done && !parents.has(item.id)));
194
- const start = Math.max(0, Math.min(focus - 2, all.length - 8));
195
- const shown = all.slice(start, start + 8);
196
- const rows = [
197
- colors.fg("accent", "Tasks") + colors.fg("dim", ` · ${done}/${total} done` + (done === total ? " · all complete" : "") + " · /jar tasks"),
198
- ...(start > 0 ? [colors.fg("dim", ` … ${start} earlier`)] : []),
199
- ...shown.map((item) => todoRow(item, (color, text) => colors.fg(color, text), (text) => colors.bold(text), parents.has(item.id) ? todoProgress(all, item.id) : undefined)),
200
- ...(start + shown.length < all.length ? [colors.fg("dim", ` … +${all.length - start - shown.length} more`)] : [])
201
- ];
202
- // Pi inserts a spacer before widgets, but not between widgets and the composer.
203
- const lines = [...rows.map((line) => truncateToWidth(line, Math.max(0, width))), truncateToWidth(" ", Math.max(0, width))];
204
- memo = { width, sample, lines };
205
- return lines.slice();
206
- }
207
- };
208
- });
181
+ ctx.ui.setWidget("pi-jar.todos", !visible ? undefined : (tui, theme) =>
182
+ taskWidget(tui, theme, () => todos?.all() ?? [], taskWidgetState));
209
183
  } catch { /* Optional widget; task data remains available via /jar tasks and jar_todo. */ }
210
184
  };
211
185
  registerTaskTool(pi, () => todos, (ctx) => updateTaskWidget(ctx));
@@ -1185,7 +1159,16 @@ export default function piJar(pi: ExtensionAPI): void {
1185
1159
  return;
1186
1160
  }
1187
1161
  if (command === "tasks" || command.startsWith("tasks ")) {
1188
- if (todos) await manageTasks(args.trim().slice(5).trim(), ctx, todos, () => updateTaskWidget(ctx));
1162
+ const taskArgs = args.trim().slice(5).trim();
1163
+ if (["collapse", "expand", "toggle"].includes(taskArgs)) {
1164
+ taskWidgetState.expanded = taskArgs === "toggle" ? !taskWidgetState.expanded : taskArgs === "expand";
1165
+ taskWidgetState.scroll = undefined;
1166
+ updateTaskWidget(ctx);
1167
+ } else if (/^scroll -?\d+$/.test(taskArgs)) {
1168
+ taskWidgetState.expanded = true;
1169
+ taskWidgetState.scroll = Math.max(0, (taskWidgetState.scroll ?? 0) + Number(taskArgs.split(" ")[1]));
1170
+ updateTaskWidget(ctx);
1171
+ } else if (todos) await manageTasks(taskArgs, ctx, todos, () => updateTaskWidget(ctx));
1189
1172
  return;
1190
1173
  }
1191
1174
  if (command === "footer") {
@@ -1201,7 +1184,7 @@ export default function piJar(pi: ExtensionAPI): void {
1201
1184
  };
1202
1185
  while (true) {
1203
1186
  const options = FOOTER_FIELDS.map((field) => `${footerSettings[field] ? "[x]" : "[ ]"} ${labels[field]}`);
1204
- const selected = await ctx.ui.select("pi-jar · footer visibility", [...options, "Done"]);
1187
+ const selected = await selectPopup(ctx, "pi-jar · footer visibility", [...options, "Done"]);
1205
1188
  const index = options.indexOf(selected ?? "");
1206
1189
  if (index < 0) break;
1207
1190
  const field = FOOTER_FIELDS[index]!;
@@ -1254,7 +1237,7 @@ export default function piJar(pi: ExtensionAPI): void {
1254
1237
  { name: "subagents-fleet", label: "Subagents · Fleet (/subagents-fleet)" }
1255
1238
  ].filter((entry) => pi.getCommands().some((item) => item.name === entry.name && item.source === "extension"));
1256
1239
  if (!native.length) { ctx.ui.notify("No task or subagent manager is installed", "info"); return; }
1257
- const selected = await ctx.ui.select("pi-jar · open existing manager", native.map((item) => item.label));
1240
+ const selected = await selectPopup(ctx, "pi-jar · open existing manager", native.map((item) => item.label));
1258
1241
  const target = native.find((item) => item.label === selected);
1259
1242
  if (target && pi.getCommands().some((item) => item.name === target.name && item.source === "extension")) {
1260
1243
  pi.sendUserMessage(`/${target.name}`, { expandPromptTemplates: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,9 +54,14 @@
54
54
  "typebox": "*"
55
55
  },
56
56
  "devDependencies": {
57
- "@earendil-works/pi-coding-agent": "^0.87.1",
58
- "@earendil-works/pi-tui": "^0.87.1",
57
+ "@earendil-works/pi-coding-agent": "^1.1.0",
58
+ "@earendil-works/pi-tui": "^1.1.0",
59
59
  "typebox": "^1.3.34",
60
60
  "typescript": "5.9.2"
61
+ },
62
+ "allowScripts": {
63
+ "@google/genai@2.21.0": true,
64
+ "esbuild@0.28.2": true,
65
+ "protobufjs@7.6.6": true
61
66
  }
62
67
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { duration, leafTodos, type DelegateRegistry, type DelegateRun, type SubagentRecord, type TranscriptEntry } from "./delegate.ts";
4
5
  import { safeLine } from "./diff-view.ts";
@@ -250,7 +251,7 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
250
251
  ctx.ui.notify("pi-jar: nothing running (subagents come from jar_delegate, shells from jar_shell)", "info");
251
252
  return;
252
253
  }
253
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
254
+ await popup<void>(ctx, (tui, theme, _keys, done) => {
254
255
  let items = opening;
255
256
  let at = initial ? items.findIndex((item) => item.kind !== "header" && item.kind === initial.kind && item.id === initial.id) : -1;
256
257
  if (at < 0) at = items.findIndex((item) => item.kind !== "header" && item.status === "running");
@@ -541,5 +542,5 @@ ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(erro
541
542
  }
542
543
  };
543
544
  return component;
544
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
545
+ });
545
546
  }
package/src/ask-tool.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { Type } from "typebox";
4
5
  import { cleanText } from "./status.ts";
@@ -33,7 +34,7 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
33
34
  })).filter((option) => !!option.label);
34
35
  if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return { kind: "cancel" };
35
36
  let abort: (() => void) | undefined;
36
- try { return await ctx.ui.custom<PickResult>((tui, theme, _keys, complete) => {
37
+ try { return await popup<PickResult>(ctx, (tui, theme, _keys, complete) => {
37
38
  const done = (result: PickResult) => { if (abort) signal?.removeEventListener("abort", abort); complete(result); };
38
39
  abort = () => done({ kind: "cancel" });
39
40
  signal?.addEventListener("abort", abort, { once: true });
package/src/delegate.ts CHANGED
@@ -1449,7 +1449,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1449
1449
  }
1450
1450
  });
1451
1451
 
1452
- const delegateTool: ToolDefinition<typeof Parameters> = {
1452
+ const delegateTool = {
1453
1453
  name: DELEGATE_TOOL,
1454
1454
  label: "delegate",
1455
1455
  description: `Start retained subagents and return immediately; the pool limit counts idle, paused and hibernated agents. fork and worktree inherit this conversation; a worktree's isolated Git changes reach /diff only when you stop it, and at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery (resolve with jar_subagent stop or discard). Turn completions arrive as pi-jar.subagent events with a short summary and changed files; jar_subagent report returns a full report. Settled agents hibernate (process closed, context kept) until resumed.`,
@@ -1459,7 +1459,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1459
1459
  "After delegating, moderate: completions arrive as pi-jar.subagent events, so keep working and never poll; don't redo delegated work; reuse retained agents (jar_subagent resume) instead of spawning; stop finished workers to apply worktree changes into /diff."
1460
1460
  ],
1461
1461
  parameters: Parameters,
1462
- async execute(_id, params, signal, onUpdate, ctx) {
1462
+ // Internal council launches use session context, not tool-only nested execution APIs.
1463
+ async execute(_id, params, signal, onUpdate, ctx: ExtensionContext) {
1463
1464
  controllerContext = ctx;
1464
1465
  const requested = params.tasks;
1465
1466
  const parentToolNames = (): string[] | undefined => {
@@ -1878,7 +1879,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1878
1879
  });
1879
1880
  return new Text(rows.join("\n") || theme.fg("dim", "No subagents."), 0, 0);
1880
1881
  }
1881
- };
1882
+ } satisfies ToolDefinition<typeof Parameters>;
1882
1883
  pi.registerTool(delegateTool);
1883
1884
 
1884
1885
  const snapshot = (record: SubagentRecord): SubagentReport => ({
package/src/dialogs.ts CHANGED
@@ -2,13 +2,14 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { todoMark, todoProgress, todoTotals, type Todo } from "./tasks.ts";
4
4
  import { cleanText } from "./status.ts";
5
+ import { popup } from "./popup.ts";
5
6
 
6
7
  const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
7
8
 
8
9
  /** Pi-jar owns these dialogs; Pi's normal and other extensions' questions are untouched. */
9
10
  export async function promptText(ctx: ExtensionContext, title: string, question: string, initial = ""): Promise<string | undefined> {
10
11
  if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
11
- return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
12
+ return popup<string | undefined>(ctx, (tui, theme, _keys, done) => {
12
13
  const input = new Input({ prompt: "❯ ", placeholder: "Type your answer" });
13
14
  input.focused = true;
14
15
  input.setValue(initial);
@@ -33,60 +34,124 @@ export async function promptText(ctx: ExtensionContext, title: string, question:
33
34
  return lines;
34
35
  }
35
36
  };
36
- });
37
+ }, { filter: false });
37
38
  }
38
39
 
39
40
  export async function promptChoice(ctx: ExtensionContext, title: string, question: string, choices: readonly string[], initial = 0): Promise<number | undefined> {
40
41
  if (!ctx.hasUI || ctx.mode !== "tui" || !choices.length) return undefined;
41
- return ctx.ui.custom<number | undefined>((tui, theme, _keys, done) => {
42
+ return popup<number | undefined>(ctx, (tui, theme, _keys, done) => {
43
+ const input = new Input({ prompt: "⌕ ", placeholder: "Type to filter" });
44
+ input.focused = true;
45
+ const labels = choices.map((choice) => cleanText(choice, 500));
46
+ let visible = choices.map((_, index) => index);
42
47
  let selected = Math.max(0, Math.min(choices.length - 1, initial));
43
48
  let firstChoiceRow = 0;
49
+ let first = 0;
50
+ let page = 10;
51
+ const choose = () => { const index = visible[selected]; if (index !== undefined) done(index); };
52
+ input.onSubmit = choose;
53
+ input.onEscape = () => done(undefined);
54
+ const refresh = () => {
55
+ const terms = input.getValue().trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
56
+ visible = labels.flatMap((label, index) => terms.every((term) => label.toLocaleLowerCase().includes(term)) ? [index] : []);
57
+ selected = 0; first = 0;
58
+ };
59
+ const move = (delta: number) => { selected = Math.max(0, Math.min(Math.max(0, visible.length - 1), selected + delta)); };
44
60
  return {
45
- invalidate() {},
61
+ get focused() { return input.focused; },
62
+ set focused(value: boolean) { input.focused = value; },
63
+ invalidate() { input.invalidate(); },
46
64
  handleInput(data: string) {
47
- if (matchesKey(data, Key.escape)) return done(undefined);
48
- if (matchesKey(data, Key.enter)) return done(selected);
49
- if (matchesKey(data, Key.up)) selected = (selected + choices.length - 1) % choices.length;
50
- if (matchesKey(data, Key.down)) selected = (selected + 1) % choices.length;
65
+ if (matchesKey(data, Key.up)) move(-1);
66
+ else if (matchesKey(data, Key.down)) move(1);
67
+ else if (matchesKey(data, Key.pageUp)) move(-page);
68
+ else if (matchesKey(data, Key.pageDown)) move(page);
69
+ else if (matchesKey(data, Key.home)) selected = 0;
70
+ else if (matchesKey(data, Key.end)) selected = Math.max(0, visible.length - 1);
71
+ else { const before = input.getValue(); input.handleInput(data); if (input.getValue() !== before) refresh(); }
51
72
  tui.requestRender();
52
73
  },
53
74
  handleMouse(event: TuiMouseEvent) {
75
+ if (event.type === "wheel" && event.wheelDelta) { move(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
54
76
  if (event.type !== "click" || event.button !== "left") return;
55
- const index = event.y - firstChoiceRow;
56
- if (index >= 0 && index < choices.length) { selected = index; done(index); return { handled: true }; }
77
+ const offset = event.y - firstChoiceRow;
78
+ if (offset >= 0 && offset < page && visible[first + offset] !== undefined) { selected = first + offset; choose(); return { handled: true }; }
57
79
  },
58
80
  render(width: number): string[] {
59
81
  const heading = [
60
82
  fit(theme.fg("accent", `╭─ ${cleanText(title, 56)} ─`), width),
61
- ...wrapTextWithAnsi(cleanText(question, 160), Math.max(1, width - 2)).map((line) => fit(theme.fg("muted", `│ ${line}`), width))
83
+ ...wrapTextWithAnsi(cleanText(question, 160), Math.max(1, width - 2)).slice(0, Math.max(0, Math.min(2, (tui.terminal?.rows ?? process.stdout.rows ?? 24) - 4))).map((line) => fit(theme.fg("muted", `│ ${line}`), width)),
84
+ ...input.render(Math.max(1, width - 2)).map((line) => fit("│ " + line, width))
62
85
  ];
63
86
  firstChoiceRow = heading.length;
87
+ page = Math.max(1, (tui.terminal?.rows ?? process.stdout.rows ?? 24) - heading.length - 1);
88
+ if (selected < first) first = selected;
89
+ if (selected >= first + page) first = selected - page + 1;
90
+ first = Math.max(0, Math.min(first, Math.max(0, visible.length - page)));
64
91
  return [
65
92
  ...heading,
66
- ...choices.map((choice, index) => fit(theme.fg(index === selected ? "accent" : "dim", `${index === selected ? "❯" : " "} ${cleanText(choice, 80)}`), width)),
67
- fit(theme.fg("dim", "╰─ ↑↓: choose · Enter: confirm · Esc: cancel"), width)
93
+ ...(visible.length ? visible.slice(first, first + page).map((original, offset) => fit(theme.fg(first + offset === selected ? "accent" : "dim", `${first + offset === selected ? "❯" : " "} ${labels[original]}`), width)) : [fit(theme.fg("dim", " No matches"), width)]),
94
+ fit(theme.fg("dim", `╰─ ${visible.length ? selected + 1 : 0}/${visible.length} · type to filter · ↑↓/Pg: choose · Enter: confirm · Esc: cancel`), width)
68
95
  ];
69
96
  }
70
97
  };
71
- });
98
+ }, { filter: false });
99
+ }
100
+
101
+ /** Searchable replacement for Pi's native selector, preserving string results. */
102
+ export async function selectPopup(ctx: ExtensionContext, title: string, choices: readonly string[]): Promise<string | undefined> {
103
+ const index = await promptChoice(ctx, title, "", choices);
104
+ return index === undefined ? undefined : choices[index];
72
105
  }
73
106
 
74
107
  export type TodoFilter = "all" | "open" | "done";
75
108
  export type TodoAction = { kind: "add" | "toggle" | "edit" | "delete"; id?: string };
76
109
 
77
- export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filter: { value: TodoFilter }): Promise<TodoAction | undefined> {
110
+ export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filter: { value: TodoFilter; collapsed?: Set<string> }): Promise<TodoAction | undefined> {
78
111
  if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
79
- return ctx.ui.custom<TodoAction | undefined>((tui, theme, _keys, done) => {
112
+ return popup<TodoAction | undefined>(ctx, (tui, theme, _keys, done) => {
80
113
  let selected = 0;
81
- const filtered = () => todos().filter((item) => filter.value === "all" || (filter.value === "done") === item.done);
114
+ const collapsed = filter.collapsed ??= new Set<string>();
115
+ const search = new Input({ prompt: "/ ", placeholder: "Filter all tasks" });
116
+ let searching = false;
117
+ search.onSubmit = () => { searching = false; search.focused = false; };
118
+ search.onEscape = () => { searching = false; search.focused = false; search.setValue(""); selected = 0; };
119
+ const filtered = () => {
120
+ const all = todos();
121
+ const byId = new Map(all.map((item) => [item.id, item]));
122
+ return all.filter((item) => {
123
+ if (!(filter.value === "all" || (filter.value === "done") === item.done)) return false;
124
+ const term = search.getValue().trim().toLocaleLowerCase();
125
+ if (term) return item.title.toLocaleLowerCase().includes(term);
126
+ let parent = item.parentId;
127
+ const seen = new Set<string>();
128
+ while (parent && !seen.has(parent)) {
129
+ if (collapsed.has(parent)) return false;
130
+ seen.add(parent); parent = byId.get(parent)?.parentId;
131
+ }
132
+ return true;
133
+ });
134
+ };
82
135
  let firstVisible = 0;
136
+ let page = 12;
83
137
  return {
84
- invalidate() {},
138
+ get focused() { return search.focused; },
139
+ set focused(value: boolean) { search.focused = searching && value; },
140
+ invalidate() { search.invalidate(); },
85
141
  handleInput(data: string) {
142
+ if (searching) { search.handleInput(data); selected = 0; tui.requestRender(); return; }
143
+ if (data === "/") { searching = true; search.focused = true; tui.requestRender(); return; }
86
144
  const items = filtered();
87
145
  if (matchesKey(data, Key.escape) || data === "q") return done(undefined);
88
146
  if (matchesKey(data, Key.up)) selected = Math.max(0, selected - 1);
89
147
  else if (matchesKey(data, Key.down)) selected = Math.min(Math.max(0, items.length - 1), selected + 1);
148
+ else if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
149
+ const item = items[selected];
150
+ if (item && todos().some((child) => child.parentId === item.id)) {
151
+ if (matchesKey(data, Key.left)) collapsed.add(item.id); else collapsed.delete(item.id);
152
+ }
153
+ } else if (matchesKey(data, Key.pageDown)) selected = Math.min(Math.max(0, items.length - 1), selected + page);
154
+ else if (matchesKey(data, Key.pageUp)) selected = Math.max(0, selected - page);
90
155
  else if (data === "f") {
91
156
  const next: Record<TodoFilter, TodoFilter> = { all: "open", open: "done", done: "all" };
92
157
  filter.value = next[filter.value]; selected = 0;
@@ -104,7 +169,7 @@ export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filte
104
169
  if (event.type !== "click" || event.button !== "left") return;
105
170
  const index = firstVisible + event.y - 1;
106
171
  const item = filtered()[index];
107
- if (event.y >= 1 && event.y <= 12 && item) {
172
+ if (event.y >= 1 && event.y <= page && item) {
108
173
  selected = index; done({ kind: "toggle", id: item.id }); return { handled: true };
109
174
  }
110
175
  },
@@ -113,19 +178,20 @@ export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filte
113
178
  selected = Math.min(selected, Math.max(0, items.length - 1));
114
179
  const all = todos();
115
180
  const totals = todoTotals(all);
116
- const start = Math.max(0, selected - 11);
181
+ page = Math.max(1, Math.min(12, (tui.terminal?.rows ?? process.stdout.rows ?? 24) - 2));
182
+ const start = Math.max(0, Math.min(selected - page + 1, items.length - page));
117
183
  firstVisible = start;
118
184
  return [
119
185
  fit(theme.fg("accent", `╭─ pi-jar to-do · ${totals.total - totals.done} open · ${filter.value} ─`), width),
120
- ...(items.length ? items.slice(start, start + 12).map((item, index) => {
186
+ ...(items.length ? items.slice(start, start + page).map((item, index) => {
121
187
  const progress = todoProgress(all, item.id);
122
- const row = `${start + index === selected ? "❯" : " "} ${item.parentId ? " " : ""}${todoMark(item)} ${item.title}${progress.total ? ` (${progress.done}/${progress.total})` : ""}`;
188
+ const row = `${start + index === selected ? "❯" : " "} ${item.parentId ? " " : ""}${progress.total ? (collapsed.has(item.id) ? "▸ " : "▾ ") : ""}${todoMark(item)} ${item.title}${progress.total ? ` (${progress.done}/${progress.total})` : ""}`;
123
189
  return fit(theme.fg(start + index === selected ? "accent" : item.done ? "dim" : item.status === "in_progress" ? "warning" : "muted", row), width);
124
190
  })
125
191
  : [fit(theme.fg("dim", " No items in this view"), width)]),
126
- fit(theme.fg("dim", "╰─ ↑↓: select · Space: check · a: add · e: edit · d: delete · f: filter · Esc: close"), width)
192
+ searching ? fit(search.render(width)[0]!, width) : fit(theme.fg("dim", "╰─ ↑↓/Pg: select · ←→: collapse/expand · /: search · Space: check · a/e/d: add/edit/delete · f: status · Esc: close"), width)
127
193
  ];
128
194
  }
129
195
  };
130
- });
196
+ }, { filter: false });
131
197
  }
package/src/diff-view.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getLanguageFromPath, highlightCode, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow, diffSplitHunkOffsets, diffSplitRowCount, diffSplitRowsWindow,
4
5
  lineDiff, MAX_REVIEW_DIFF_LINES, type ChangeTracker, type DiffOp, type FileChange } from "./changes.ts";
@@ -172,7 +173,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
172
173
  if (!ctx.hasUI || ctx.mode !== "tui") return;
173
174
  let changes = tracker.changes();
174
175
  if (!changes.length) { ctx.ui.notify("pi-jar: no agent changes to review", "info"); return; }
175
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
176
+ await popup<void>(ctx, (tui, theme, _keys, done) => {
176
177
  let selected = 0;
177
178
  let scroll = 0;
178
179
  let listScroll = 0;
@@ -386,7 +387,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
386
387
  }
387
388
  };
388
389
  return component;
389
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
390
+ }, { filter: false });
390
391
  }
391
392
 
392
393
  /** Track edit/write targets before they run, and expose `/diff` plus ctrl+alt+d. */
package/src/history-ui.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryChunk, type HistoryItem } from "./history.ts";
4
5
  import { RowCache } from "./row-cache.ts";
@@ -9,7 +10,7 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
9
10
  if (!ctx.hasUI || ctx.mode !== "tui") return;
10
11
  // Capture the active branch ONCE on open. Paging and search operate on this snapshot.
11
12
  const snapshot = createHistorySnapshot(sessionBranch(ctx) as ReturnType<typeof ctx.sessionManager.getBranch>);
12
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
13
+ await popup<void>(ctx, (tui, theme, _keys, done) => {
13
14
  let current = historyPage(snapshot);
14
15
  let selected = Math.max(0, current.items.length - 1);
15
16
  let expanded = false;
@@ -140,5 +141,5 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
140
141
  border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")];
141
142
  }
142
143
  };
143
- }, { overlay: true, overlayOptions: { anchor: "center", width: 92, maxHeight: "80%" } });
144
+ }, { filter: false });
144
145
  }
package/src/panel.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { RowCache } from "./row-cache.ts";
4
5
 
@@ -8,7 +9,7 @@ export interface PanelTab { name: string; render(width: number, fg: Paint): stri
8
9
  /** Tabbed, scrollable info panel (Usage / Context / Cache). Tab or click switches tabs; Esc closes. */
9
10
  export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[], initial = 0): Promise<void> {
10
11
  if (!ctx.hasUI || ctx.mode !== "tui") return;
11
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
12
+ await popup<void>(ctx, (tui, theme, _keys, done) => {
12
13
  let tab = Math.max(0, Math.min(tabs.length - 1, initial));
13
14
  let scroll = 0;
14
15
  let body: string[] = [];
@@ -64,7 +65,7 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
64
65
  ];
65
66
  }
66
67
  };
67
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
68
+ });
68
69
  }
69
70
 
70
71
  export const tokens = (value: number): string => value < 1000 ? String(Math.round(value))
package/src/plan-view.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getMarkdownTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, Markdown, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { parsePlanSections, type PlanSection } from "./plan-utils.ts";
4
5
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
@@ -45,7 +46,7 @@ export async function openPlanView(ctx: ExtensionContext, input: PlanViewInput):
45
46
  const lines = input.text.replace(/\r/g, "").split("\n");
46
47
  const { title, entries } = planEntries(input.text);
47
48
  const roleChoices = ["current", ...(input.roles ?? [])];
48
- return ctx.ui.custom<PlanViewResult | undefined>((tui, theme, _keys, done) => {
49
+ return popup<PlanViewResult | undefined>(ctx, (tui, theme, _keys, done) => {
49
50
  let selected = 0;
50
51
  let focus: Focus = "toc";
51
52
  let action = 0;
@@ -145,5 +146,5 @@ export async function openPlanView(ctx: ExtensionContext, input: PlanViewInput):
145
146
  return split.lines;
146
147
  }
147
148
  };
148
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
149
+ });
149
150
  }
package/src/popup.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Input, Key, matchesKey, stripTerminalSequences, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+
4
+ type Factory<T> = (tui: TUI, theme: Theme, keys: KeybindingsManager, done: (result: T) => void) => Component & { dispose?(): void };
5
+ export interface PopupOptions {
6
+ /** Views with their own search/editor keep ownership of printable keys. */
7
+ filter?: boolean;
8
+ }
9
+
10
+ /** Common containment for every jar popup. Oversized chrome remains reachable, not silently clipped by Pi. */
11
+ export function popup<T>(ctx: ExtensionContext, factory: Factory<T>, options: PopupOptions = {}): Promise<T> {
12
+ return ctx.ui.custom<T>((tui, theme, keys, done) => {
13
+ const child = factory(tui, theme, keys, done);
14
+ const input = new Input({ prompt: "/ ", placeholder: "Filter popup content" });
15
+ let searching = false;
16
+ let scroll = 0;
17
+ let height = 24;
18
+ let width = 80;
19
+ let source: string[] = [];
20
+ let hits: number[] = [];
21
+ let shown: number[] = [];
22
+ let follow = false;
23
+ const page = () => Math.max(1, height - 2);
24
+ const move = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, hits.length - page()), scroll + delta)); };
25
+ input.onEscape = () => { searching = false; input.focused = false; input.setValue(""); scroll = 0; };
26
+ input.onSubmit = () => {
27
+ // Return to the original view, never activate a hidden selection on Enter.
28
+ scroll = hits[scroll] ?? 0;
29
+ searching = false; input.focused = false; input.setValue("");
30
+ };
31
+ return {
32
+ get focused() { return searching ? input.focused : (child as Component & { focused?: boolean }).focused ?? false; },
33
+ set focused(value: boolean) { input.focused = searching && value; (child as Component & { focused?: boolean }).focused = value; },
34
+ invalidate() { child.invalidate(); input.invalidate(); },
35
+ dispose() { child.dispose?.(); },
36
+ handleInput(data: string) {
37
+ if (searching) {
38
+ if (matchesKey(data, Key.up)) move(-1);
39
+ else if (matchesKey(data, Key.down)) move(1);
40
+ else if (matchesKey(data, Key.pageUp)) move(-page());
41
+ else if (matchesKey(data, Key.pageDown)) move(page());
42
+ else { const before = input.getValue(); input.handleInput(data); if (input.getValue() !== before) scroll = 0; }
43
+ } else if (options.filter !== false && data === "/") {
44
+ searching = true; input.focused = true; scroll = 0;
45
+ } else if (matchesKey(data, Key.ctrl("pageUp"))) move(-page());
46
+ else if (matchesKey(data, Key.ctrl("pageDown"))) move(page());
47
+ else { child.handleInput?.(data); follow = true; }
48
+ tui.requestRender();
49
+ },
50
+ handleMouse(event) {
51
+ if (event.type === "wheel" && event.wheelDelta && (searching || source.length > height)) {
52
+ move(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true };
53
+ }
54
+ if (event.type === "wheel") return child.handleMouse?.(event);
55
+ const original = shown[event.y];
56
+ if (original === undefined || original < 0) return;
57
+ return child.handleMouse?.({ ...event, y: original });
58
+ },
59
+ render(available) {
60
+ width = Math.max(1, available);
61
+ height = Math.max(1, tui.terminal?.rows ?? process.stdout.rows ?? 24);
62
+ source = child.render(width);
63
+ const terms = input.getValue().trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
64
+ hits = source.flatMap((line, index) => {
65
+ const text = stripTerminalSequences(line).toLocaleLowerCase();
66
+ return !searching || (/[\p{L}\p{N}]/u.test(text) && terms.every((term) => text.includes(term))) ? [index] : [];
67
+ });
68
+ if (!searching && source.length <= height) {
69
+ scroll = 0; shown = hits; follow = false; return source.map((line) => truncateToWidth(line, width));
70
+ }
71
+ if (follow && !searching) {
72
+ const selected = source.findIndex((line) => stripTerminalSequences(line).includes("❯"));
73
+ if (selected >= 0) {
74
+ if (selected < scroll) scroll = selected;
75
+ if (selected >= scroll + page()) scroll = selected - page() + 1;
76
+ }
77
+ }
78
+ follow = false;
79
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, hits.length - page())));
80
+ const window = hits.slice(scroll, scroll + page());
81
+ const body = window.length ? window.map((at) => source[at]!) : [theme.fg("dim", "No matches")];
82
+ const header = searching ? input.render(width)[0]! : theme.fg("dim", "↑↓ in view · Ctrl+PgUp/PgDn / wheel scroll popup" + (options.filter !== false ? " · / filter" : ""));
83
+ const footer = theme.fg("dim", `${hits.length ? scroll + 1 : 0}–${Math.min(hits.length, scroll + page())}/${hits.length}` + (searching ? " · Enter return to view · click to act · Esc clear" : ""));
84
+ if (height <= 2) {
85
+ shown = [...window.slice(0, 1), -1];
86
+ return [body[0]!, footer].slice(0, height).map((line) => truncateToWidth(line, width));
87
+ }
88
+ shown = [-1, ...window, -1];
89
+ // Header/footer have no child hit target, even on a one-row terminal.
90
+ shown[0] = -1;
91
+ return [header, ...body, footer].slice(0, height).map((line) => truncateToWidth(line, width));
92
+ }
93
+ };
94
+ }, { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0 } });
95
+ }
package/src/profile-ui.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { JarProfile, ProfileStore } from "./profiles.ts";
3
3
  import type { JarVisualSettings } from "./settings.ts";
4
+ import { selectPopup } from "./dialogs.ts";
4
5
 
5
6
  export type ProfileAction = "create" | "switch";
6
7
 
@@ -35,7 +36,7 @@ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<Prof
35
36
  if (options.store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
36
37
  ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
37
38
  }
38
- const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
39
+ const selected = await selectPopup(ctx, "Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
39
40
  if (!selected) return undefined;
40
41
  let profile: JarProfile;
41
42
  try { profile = options.create(normalized, selected === FOLLOW ? "follow" : selected); }
@@ -55,7 +56,7 @@ export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOpti
55
56
  let preset: Partial<JarVisualSettings> | undefined;
56
57
  if (action !== "create") {
57
58
  const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
58
- const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, SWARM, ...labels.keys()]);
59
+ const choice = await selectPopup(ctx, action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, SWARM, ...labels.keys()]);
59
60
  if (!choice) return;
60
61
  if (choice !== CREATE && choice !== SWARM) { await options.select(labels.get(choice)!); return; }
61
62
  if (choice === SWARM) preset = SWARM_SETTINGS;
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { fuzzyFilter, Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { cleanText } from "./status.ts";
4
5
 
@@ -49,14 +50,14 @@ export function collectPrompts(entries: readonly unknown[], earlier: readonly {
49
50
  export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly PastPrompt[]): Promise<string | undefined> {
50
51
  if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
51
52
  if (!prompts.length) { ctx.ui.notify("pi-jar: no earlier prompts yet", "info"); return undefined; }
52
- return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
53
+ return popup<string | undefined>(ctx, (tui, theme, _keys, done) => {
53
54
  let query = "";
54
55
  let selected = 0;
55
56
  let first = 0;
56
57
  // pi-tui's fuzzyFilter takes a mutable array; copy once per overlay, not per keystroke.
57
58
  const pool: PastPrompt[] = prompts.slice();
58
59
  let visible: PastPrompt[] = pool;
59
- const rows = 10;
60
+ let rows = 10;
60
61
  const filter = () => { visible = query ? fuzzyFilter(pool, query, (item) => item.text) : pool; selected = 0; first = 0; };
61
62
  // Prompts can be several KiB; sanitize each once, not on every repaint.
62
63
  const cleaned = new Map<PastPrompt, string>();
@@ -85,6 +86,7 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
85
86
  },
86
87
  render(width: number): string[] {
87
88
  const w = Math.max(20, width);
89
+ rows = Math.max(1, Math.min(10, (tui.terminal?.rows ?? process.stdout.rows ?? 24) - 3));
88
90
  const inner = w - 4;
89
91
  if (selected < first) first = selected;
90
92
  if (selected >= first + rows) first = selected - rows + 1;
@@ -106,5 +108,5 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
106
108
  return lines;
107
109
  }
108
110
  };
109
- }, { overlay: true, overlayOptions: { width: "80%", maxHeight: "60%" } });
111
+ }, { filter: false });
110
112
  }
package/src/roles-ui.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, stripTerminalSequences, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
- import { promptText } from "./dialogs.ts";
2
+ import { Input, Key, matchesKey, stripTerminalSequences, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { promptText, selectPopup } from "./dialogs.ts";
4
+ import { popup } from "./popup.ts";
4
5
  import { isRoleName, normalizeSpec, premiumRoleWarnings, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
5
6
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
6
7
 
@@ -38,19 +39,31 @@ function detail(row: RoleRow, active: string | undefined): [ThemeColor, string][
38
39
  }
39
40
 
40
41
  async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initial: number): Promise<{ action: RoleAction; index: number } | undefined> {
41
- return ctx.ui.custom<{ action: RoleAction; index: number } | undefined>((tui, theme, _keys, done) => {
42
+ return popup<{ action: RoleAction; index: number } | undefined>(ctx, (tui, theme, _keys, done) => {
42
43
  let selected = initial;
43
44
  let scroll = 0;
44
45
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
45
46
  let width = 80;
46
- const rows = () => roles.list();
47
+ const input = new Input({ prompt: "/ ", placeholder: "Filter roles" });
48
+ let searching = false;
49
+ const rows = () => roles.list().filter((row) => `${row.role} ${row.label} ${row.spec ?? ""}`.toLocaleLowerCase().includes(input.getValue().trim().toLocaleLowerCase()));
50
+ input.onSubmit = () => { searching = false; input.focused = false; };
51
+ input.onEscape = () => { searching = false; input.focused = false; input.setValue(""); selected = 0; scroll = 0; };
47
52
  // Read once per screen: every action closes the screen, and reopening it re-checks against the current model.
48
53
  const warnings = premiumRoleWarnings(roles, ctx);
49
- const finish = (action: RoleAction) => done({ action, index: selected });
54
+ const finish = (action: RoleAction) => {
55
+ const row = rows()[selected];
56
+ if (row) done({ action, index: roles.list().findIndex((item) => item.role === row.role) });
57
+ };
50
58
  return {
51
- invalidate() {},
59
+ get focused() { return input.focused; },
60
+ set focused(value: boolean) { input.focused = searching && value; },
61
+ invalidate() { input.invalidate(); },
52
62
  handleInput(data: string) {
63
+ if (searching) { input.handleInput(data); selected = 0; scroll = 0; tui.requestRender(); return; }
64
+ if (data === "/") { searching = true; input.focused = true; tui.requestRender(); return; }
53
65
  const count = rows().length;
66
+ if (!count && !matchesKey(data, Key.escape)) return;
54
67
  if (matchesKey(data, Key.escape) || data === "q") return done(undefined);
55
68
  if (matchesKey(data, Key.up) || data === "k") selected = (selected + count - 1) % count;
56
69
  else if (matchesKey(data, Key.down) || data === "j") selected = (selected + 1) % count;
@@ -93,7 +106,8 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
93
106
  const mark = row.error ? "⚠" : row.resolved ? "●" : "○";
94
107
  return theme.fg(current ? "accent" : row.resolved ? "muted" : "dim", (current ? "❯ " : " ") + mark + " " + row.role + (active === row.role ? " *" : ""));
95
108
  });
96
- const row = list[selected]!;
109
+ const row = list[selected];
110
+ if (!row) return [theme.fg("accent", "pi-jar · roles"), ...input.render(width), theme.fg("dim", "No matching roles · / edit filter · Esc close")];
97
111
  const info = detail(row, active).map(([color, text]) => theme.fg(color, text));
98
112
  // Narrow terminals collapse the sidebar into a pager header above the details.
99
113
  const narrow = sidebarWidth(width) === 0;
@@ -103,13 +117,13 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
103
117
  const body = [...header, ...warnings.map((text) => theme.fg("warning", text)), ...info];
104
118
  const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
105
119
  ...actions,
106
- theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
120
+ searching ? input.render(width)[0]! : theme.fg("dim", "↑↓ choose role · / filter roles · press a key or click an action · Esc close")
107
121
  ], height);
108
122
  layout = split.layout;
109
123
  return split.lines;
110
124
  }
111
125
  };
112
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
126
+ }, { filter: false });
113
127
  }
114
128
 
115
129
  /** Keyboard- and pointer-driven model role manager. */
@@ -146,7 +160,7 @@ async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row:
146
160
  const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
147
161
  if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
148
162
  const labels = models.map((model) => model.provider + "/" + model.id);
149
- const selected = await ctx.ui.select("Model for " + row.role, labels);
163
+ const selected = await selectPopup(ctx, "Model for " + row.role, labels);
150
164
  if (selected) roles.update(row.role, selected + effort(), scope);
151
165
  return;
152
166
  }
@@ -154,20 +168,20 @@ async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row:
154
168
  const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
155
169
  if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
156
170
  const choices = ["Clear fallback", ...models.map((model) => model.provider + "/" + model.id)];
157
- const selected = await ctx.ui.select("Fallback model for " + row.role, choices);
171
+ const selected = await selectPopup(ctx, "Fallback model for " + row.role, choices);
158
172
  if (!selected) return;
159
173
  roles.updateFallbacks(row.role, selected === "Clear fallback" ? [] : [selected], scope);
160
174
  return;
161
175
  }
162
176
  case "alias": {
163
177
  const targets = roles.list().filter((item) => item.role !== row.role).map((item) => "@" + item.role);
164
- const selected = await ctx.ui.select("Alias " + row.role + " to", targets);
178
+ const selected = await selectPopup(ctx, "Alias " + row.role + " to", targets);
165
179
  if (selected) roles.update(row.role, selected + effort(), scope);
166
180
  return;
167
181
  }
168
182
  case "thinking": {
169
183
  if (!row.spec) { ctx.ui.notify("Assign a model or alias first", "warning"); return; }
170
- const selected = await ctx.ui.select("Thinking effort for " + row.role, ["follow current", ...THINKING]);
184
+ const selected = await selectPopup(ctx, "Thinking effort for " + row.role, ["follow current", ...THINKING]);
171
185
  if (selected) roles.update(row.role, base() + (selected === "follow current" ? "" : ":" + selected), scope);
172
186
  return;
173
187
  }
package/src/session-ui.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext, SessionInfo } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { sessionDisplayName } from "./composer.ts";
4
5
  import { iconSet, withIcon } from "./icons.ts";
@@ -45,7 +46,7 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
45
46
  for (const item of indexed) if (terms.every((term) => item.haystack.includes(term))) rows.push(item.session);
46
47
  return rows;
47
48
  };
48
- return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
49
+ return popup<string | undefined>(ctx, (tui, theme, _keys, done) => {
49
50
  let query = initial.slice(0, 100);
50
51
  let selected = 0;
51
52
  let first = 0;
@@ -151,5 +152,5 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
151
152
  return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
152
153
  }
153
154
  };
154
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
155
+ }, { filter: false });
155
156
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { popup } from "./popup.ts";
2
3
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
4
  import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
4
5
  import { formatTokens } from "./context-budget.ts";
@@ -34,7 +35,7 @@ export async function openJarSettings(
34
35
  const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
35
36
  let lastPage: Page = "appearance";
36
37
  while (true) {
37
- const action = await ctx.ui.custom<ProfileAction | undefined>((tui, theme, _keys, done) => {
38
+ const action = await popup<ProfileAction | undefined>(ctx, (tui, theme, _keys, done) => {
38
39
  let page: Page = lastPage;
39
40
  let selected = 0;
40
41
  let width = 64;
package/src/split-view.ts CHANGED
@@ -66,7 +66,7 @@ export function splitFrame(theme: SplitTheme, width: number, title: string, left
66
66
  for (const line of footer) lines.push(dim("│ ") + pad(padded.footer, line, w - 4) + dim(" │"));
67
67
  lines.push(dim("╰" + "─".repeat(w - 2) + "╯"));
68
68
  // Every row is assembled from exact-width parts; only a sidebar too wide for the frame can overflow.
69
- const fitted = bodyWidth < 0 ? lines.map((line) => truncateToWidth(line, w)) : lines;
69
+ const fitted = bodyWidth < 0 || width < w ? lines.map((line) => truncateToWidth(line, Math.max(0, width))) : lines;
70
70
  const layout = { top: 1, rows, leftWidth: split ? leftWidth : 0, bodyX: split ? leftWidth + 4 : 2, footerTop };
71
71
  previous = { key, left: left.slice(0, rows), right: right.slice(0, rows), footer: footer.slice(), lines: fitted, layout };
72
72
  return { lines: fitted.slice(), layout: { ...layout } };
@@ -74,7 +74,7 @@ export function splitFrame(theme: SplitTheme, width: number, title: string, left
74
74
 
75
75
  /** Available content rows for a full-height overlay with `chrome` fixed rows. */
76
76
  export function contentRows(chrome: number, min = 4): number {
77
- return Math.max(min, (process.stdout.rows ?? 24) - chrome);
77
+ return Math.max(1, (process.stdout.rows ?? 24) - chrome);
78
78
  }
79
79
 
80
80
  /**
@@ -0,0 +1,51 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { todoProgress, todoTotals, type Todo } from "./tasks.ts";
4
+ import { todoRow } from "./task-tool.ts";
5
+
6
+ /** Retained across widget refreshes. Compact by default so tasks never bury the composer. */
7
+ export interface TaskWidgetState { expanded: boolean; scroll: number | undefined }
8
+ export function taskWidget(tui: TUI, theme: Theme, items: () => Todo[], state: TaskWidgetState) {
9
+ let page = 4;
10
+ const toggle = () => { state.expanded = !state.expanded; tui.requestRender(); };
11
+ const move = (delta: number) => {
12
+ state.scroll = Math.max(0, Math.min(Math.max(0, items().length - page), (state.scroll ?? 0) + delta));
13
+ tui.requestRender();
14
+ };
15
+ return {
16
+ invalidate() {},
17
+ handleInput(data: string) {
18
+ if (matchesKey(data, Key.enter) || data === " ") toggle();
19
+ else if (matchesKey(data, Key.down)) move(1);
20
+ else if (matchesKey(data, Key.up)) move(-1);
21
+ else if (matchesKey(data, Key.pageDown)) move(page);
22
+ else if (matchesKey(data, Key.pageUp)) move(-page);
23
+ },
24
+ handleMouse(event: TuiMouseEvent) {
25
+ if (event.type === "click" && event.button === "left" && event.y === 0) { toggle(); return { handled: true }; }
26
+ if (event.type === "wheel" && event.wheelDelta && state.expanded) { move(Math.sign(event.wheelDelta) * 3); return { handled: true }; }
27
+ },
28
+ render(width: number) {
29
+ const all = items();
30
+ const { done, total } = todoTotals(all);
31
+ const parents = new Set(all.flatMap((item) => item.parentId ? [item.parentId] : []));
32
+ const running = all.findIndex((item) => item.status === "in_progress" && !parents.has(item.id));
33
+ const focus = Math.max(0, running >= 0 ? running : all.findIndex((item) => !item.done && !parents.has(item.id)));
34
+ // At most a quarter of the terminal, including heading and spacer.
35
+ const budget = Math.max(1, Math.floor((tui.terminal?.rows ?? process.stdout.rows ?? 24) / 4));
36
+ page = Math.max(1, Math.min(4, budget - 2));
37
+ const start = Math.max(0, Math.min(state.scroll ?? Math.max(0, focus - 1), Math.max(0, all.length - page)));
38
+ const current = all[focus];
39
+ const heading = theme.fg("accent", `${state.expanded ? "▾" : "▸"} Tasks`) + theme.fg("dim", ` · ${done}/${total} done`)
40
+ + (!state.expanded && current && !current.done ? theme.fg("muted", ` · ${current.title}`) : "")
41
+ + theme.fg("dim", ` · click to ${state.expanded ? "collapse" : "expand"} · /jar tasks`);
42
+ const rows = [heading];
43
+ if (state.expanded && budget > 2) {
44
+ rows[0] += theme.fg("dim", ` · ${start + 1}–${Math.min(all.length, start + page)}/${all.length} · wheel scroll`);
45
+ rows.push(...all.slice(start, start + page).map((item) => todoRow(item, (color, text) => theme.fg(color, text), (text) => theme.bold(text), parents.has(item.id) ? todoProgress(all, item.id) : undefined)));
46
+ }
47
+ rows.push(" ");
48
+ return rows.slice(0, Math.max(1, budget)).map((line) => truncateToWidth(line, Math.max(0, width)));
49
+ }
50
+ };
51
+ }