pi-jar 0.2.6 → 0.2.7

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
@@ -93,6 +93,8 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
93
93
 
94
94
  ## Popup navigation
95
95
 
96
+ Pi-jar questions (`jar_ask`) sit at the bottom above other pi-jar popups, which resume when the question finishes. Their compact option list supports ↑/↓ selection, PgUp/PgDn or wheel scrolling, and shows descriptions for the selected option only.
97
+
96
98
  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
99
 
98
100
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",
package/src/ask-tool.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { popup } from "./popup.ts";
3
- import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import { acquirePopupPriority } from "./popup.ts";
3
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type OverlayHandle, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import { cleanText } from "./status.ts";
6
6
 
@@ -34,7 +34,9 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
34
34
  })).filter((option) => !!option.label);
35
35
  if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return { kind: "cancel" };
36
36
  let abort: (() => void) | undefined;
37
- try { return await popup<PickResult>(ctx, (tui, theme, _keys, complete) => {
37
+ const release = acquirePopupPriority();
38
+ let handle: OverlayHandle | undefined;
39
+ try { return await ctx.ui.custom<PickResult>((tui, theme, _keys, complete) => {
38
40
  const done = (result: PickResult) => { if (abort) signal?.removeEventListener("abort", abort); complete(result); };
39
41
  abort = () => done({ kind: "cancel" });
40
42
  signal?.addEventListener("abort", abort, { once: true });
@@ -51,6 +53,8 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
51
53
  let hitRows: number[] = [];
52
54
  let scroll = 0;
53
55
  let lastSelected = selected;
56
+ let page = 10;
57
+ let reveal = false;
54
58
  const totalRows = () => options.length + actions.length;
55
59
  const choose = () => {
56
60
  if (selected < options.length) {
@@ -72,6 +76,10 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
72
76
  if (matchesKey(data, Key.escape)) return done({ kind: "cancel" });
73
77
  if (matchesKey(data, Key.up)) selected = (selected + totalRows() - 1) % totalRows();
74
78
  else if (matchesKey(data, Key.down)) selected = (selected + 1) % totalRows();
79
+ else if (matchesKey(data, Key.pageDown)) { scroll += page; }
80
+ else if (matchesKey(data, Key.pageUp)) { scroll = Math.max(0, scroll - page); }
81
+ else if (matchesKey(data, Key.home)) { selected = 0; reveal = true; }
82
+ else if (matchesKey(data, Key.end)) { selected = totalRows() - 1; reveal = true; }
75
83
  else if (multi && data === "a") {
76
84
  // Select all, or clear when everything is already checked.
77
85
  if (checked.size === options.length) checked.clear(); else options.forEach((_option, at) => checked.add(at));
@@ -95,14 +103,14 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
95
103
  return { handled: true };
96
104
  },
97
105
  render(width: number): string[] {
106
+ // Pi exposes focus order rather than a numeric z-index; focus brings this overlay to the front.
107
+ if (handle && !handle.isFocused()) handle.focus();
98
108
  hitRows = [];
109
+ const height = Math.max(1, tui.terminal?.rows ?? process.stdout.rows ?? 24);
99
110
  const inner = Math.max(1, width - 4);
100
111
  const badge = "◆ QUESTION " + String(index + 1) + "/" + String(total) + (multi ? " · MULTI SELECT" : "");
101
- const title = question.header ? cleanText(question.header, 48) : "Clarification";
102
- const lines: string[] = [
103
- fit(theme.fg("accent", "╭─ " + badge + " ─"), width),
104
- fit(theme.fg("muted", "│ " + title), width)
105
- ];
112
+ const title = question.header ? " · " + cleanText(question.header, 48) : "";
113
+ const lines: string[] = [fit(theme.fg("accent", "╭─ " + badge + title), width)];
106
114
  const content: { line: string; target?: number }[] = [];
107
115
  for (const row of wrapTextWithAnsi(cleanText(question.question, 900), inner)) content.push({ line: fit("│ " + row, width) });
108
116
  content.push({ line: fit(theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 1))), width) });
@@ -111,7 +119,11 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
111
119
  const marker = multi ? (checked.has(at) ? "☑" : "☐") : (active ? "●" : "○");
112
120
  const number = String(at + 1).padStart(2, " ") + ".";
113
121
  content.push({ target: at, line: fit(theme.fg(active ? "accent" : "muted", "│ " + (active ? "❯ " : " ") + marker + " " + number + " " + option.label), width) });
114
- if (option.description) content.push({ target: at, line: fit(theme.fg("dim", "│ " + option.description), width) });
122
+ if (option.description && active) {
123
+ for (const description of wrapTextWithAnsi(option.description, Math.max(1, inner - 6)).slice(0, 2)) {
124
+ content.push({ target: at, line: fit(theme.fg("dim", "│ " + description), width) });
125
+ }
126
+ }
115
127
  });
116
128
  actions.forEach((action, at) => {
117
129
  const row = options.length + at;
@@ -120,24 +132,36 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
120
132
  const chip = "[ " + action.icon + " " + label + " ]";
121
133
  content.push({ target: row, line: fit(theme.fg(active ? "accent" : "dim", "│ " + (active ? "❯ " : " ") + chip), width) });
122
134
  });
123
- const page = Math.max(3, Math.min(16, (process.stdout.rows ?? 24) - 5));
124
- if (selected !== lastSelected) {
135
+ // One viewport owns all question text/options/actions: no nested generic popup scrolling.
136
+ page = Math.max(1, Math.min(12, height - 2));
137
+ if (selected !== lastSelected || reveal) {
125
138
  const target = content.findIndex((row) => row.target === selected);
126
139
  if (target < scroll) scroll = target;
127
140
  else if (target >= scroll + page) scroll = target - page + 1;
128
141
  lastSelected = selected;
142
+ reveal = false;
129
143
  }
130
144
  scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - page)));
131
145
  content.slice(scroll, scroll + page).forEach((row) => {
132
146
  if (row.target !== undefined) hitRows[lines.length] = row.target;
133
147
  lines.push(row.line);
134
148
  });
135
- const hint = multi ? `${checked.size}/${options.length} checked · ↑↓ move · Space/Enter toggle · a all · Esc cancel` : "↑↓ move · Enter choose · wheel scroll · Esc cancel";
149
+ const hint = multi ? `${checked.size}/${options.length} checked · ↑↓ · Space toggle · Enter · a all · Esc` : "↑↓ choose · Enter · Pg/wheel scroll · Esc";
136
150
  lines.push(fit(theme.fg("dim", `╰─ ${scroll + 1}–${Math.min(scroll + page, content.length)}/${content.length} · ${hint}`), width));
137
- return lines.map((line) => visibleWidth(line) <= width ? line : fit(line, width));
151
+ if (height <= 2) {
152
+ const body = lines.slice(1, 1 + page);
153
+ const remapped = body.map((_line, row) => hitRows[row + 1]);
154
+ hitRows = remapped;
155
+ return body.slice(0, height).map((line) => fit(line, width));
156
+ }
157
+ return lines.slice(0, height).map((line) => visibleWidth(line) <= width ? line : fit(line, width));
138
158
  }
139
159
  };
140
- }); } finally { if (abort) signal?.removeEventListener("abort", abort); }
160
+ }, {
161
+ overlay: true,
162
+ overlayOptions: { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 0 },
163
+ onHandle(value) { handle = value; handle.focus(); }
164
+ }); } finally { release(); if (abort) signal?.removeEventListener("abort", abort); }
141
165
  }
142
166
 
143
167
  export async function askOne(ctx: ExtensionContext, question: AskQuestion, index = 0, total = 1, signal?: AbortSignal): Promise<AskAnswer> {
package/src/popup.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Key, matchesKey, stripTerminalSequences, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
3
 
4
+ let priorityPopups = 0;
5
+ /** Hide ordinary jar overlays while a blocking question owns the screen. */
6
+ export function acquirePopupPriority(): () => void {
7
+ priorityPopups++;
8
+ let released = false;
9
+ return () => { if (!released) { released = true; priorityPopups--; } };
10
+ }
11
+
4
12
  type Factory<T> = (tui: TUI, theme: Theme, keys: KeybindingsManager, done: (result: T) => void) => Component & { dispose?(): void };
5
13
  export interface PopupOptions {
6
14
  /** Views with their own search/editor keep ownership of printable keys. */
@@ -91,5 +99,5 @@ export function popup<T>(ctx: ExtensionContext, factory: Factory<T>, options: Po
91
99
  return [header, ...body, footer].slice(0, height).map((line) => truncateToWidth(line, width));
92
100
  }
93
101
  };
94
- }, { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0 } });
102
+ }, { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0, visible: () => priorityPopups === 0 } });
95
103
  }