pi-jar 0.2.5 → 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 +10 -1
- package/extensions/index.ts +19 -36
- package/package.json +1 -1
- package/src/activity-view.ts +3 -2
- package/src/ask-tool.ts +37 -12
- package/src/dialogs.ts +90 -24
- package/src/diff-view.ts +3 -2
- package/src/history-ui.ts +3 -2
- package/src/panel.ts +3 -2
- package/src/plan-view.ts +3 -2
- package/src/popup.ts +103 -0
- package/src/profile-ui.ts +3 -2
- package/src/prompt-search.ts +5 -3
- package/src/roles-ui.ts +27 -13
- package/src/session-ui.ts +3 -2
- package/src/settings-ui.ts +2 -1
- package/src/split-view.ts +2 -2
- package/src/task-widget.ts +51 -0
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]` |
|
|
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,14 @@ 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 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
|
+
|
|
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.
|
|
99
|
+
|
|
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.
|
|
101
|
+
|
|
93
102
|
## Profiles
|
|
94
103
|
|
|
95
104
|
Profiles keep separate themes, appearance settings, and model-role configurations. **Default** preserves your existing setup.
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
39
|
-
import { TASK_ENTRY, TodoStore,
|
|
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 : (
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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/activity-view.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, 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
|
|
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
|
-
}
|
|
545
|
+
});
|
|
545
546
|
}
|
package/src/ask-tool.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { acquirePopupPriority } from "./popup.ts";
|
|
3
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type OverlayHandle, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
4
|
import { Type } from "typebox";
|
|
4
5
|
import { cleanText } from "./status.ts";
|
|
5
6
|
|
|
@@ -33,6 +34,8 @@ 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;
|
|
37
|
+
const release = acquirePopupPriority();
|
|
38
|
+
let handle: OverlayHandle | undefined;
|
|
36
39
|
try { return await ctx.ui.custom<PickResult>((tui, theme, _keys, complete) => {
|
|
37
40
|
const done = (result: PickResult) => { if (abort) signal?.removeEventListener("abort", abort); complete(result); };
|
|
38
41
|
abort = () => done({ kind: "cancel" });
|
|
@@ -50,6 +53,8 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
|
|
|
50
53
|
let hitRows: number[] = [];
|
|
51
54
|
let scroll = 0;
|
|
52
55
|
let lastSelected = selected;
|
|
56
|
+
let page = 10;
|
|
57
|
+
let reveal = false;
|
|
53
58
|
const totalRows = () => options.length + actions.length;
|
|
54
59
|
const choose = () => {
|
|
55
60
|
if (selected < options.length) {
|
|
@@ -71,6 +76,10 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
|
|
|
71
76
|
if (matchesKey(data, Key.escape)) return done({ kind: "cancel" });
|
|
72
77
|
if (matchesKey(data, Key.up)) selected = (selected + totalRows() - 1) % totalRows();
|
|
73
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; }
|
|
74
83
|
else if (multi && data === "a") {
|
|
75
84
|
// Select all, or clear when everything is already checked.
|
|
76
85
|
if (checked.size === options.length) checked.clear(); else options.forEach((_option, at) => checked.add(at));
|
|
@@ -94,14 +103,14 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
|
|
|
94
103
|
return { handled: true };
|
|
95
104
|
},
|
|
96
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();
|
|
97
108
|
hitRows = [];
|
|
109
|
+
const height = Math.max(1, tui.terminal?.rows ?? process.stdout.rows ?? 24);
|
|
98
110
|
const inner = Math.max(1, width - 4);
|
|
99
111
|
const badge = "◆ QUESTION " + String(index + 1) + "/" + String(total) + (multi ? " · MULTI SELECT" : "");
|
|
100
|
-
const title = question.header ? cleanText(question.header, 48) : "
|
|
101
|
-
const lines: string[] = [
|
|
102
|
-
fit(theme.fg("accent", "╭─ " + badge + " ─"), width),
|
|
103
|
-
fit(theme.fg("muted", "│ " + title), width)
|
|
104
|
-
];
|
|
112
|
+
const title = question.header ? " · " + cleanText(question.header, 48) : "";
|
|
113
|
+
const lines: string[] = [fit(theme.fg("accent", "╭─ " + badge + title), width)];
|
|
105
114
|
const content: { line: string; target?: number }[] = [];
|
|
106
115
|
for (const row of wrapTextWithAnsi(cleanText(question.question, 900), inner)) content.push({ line: fit("│ " + row, width) });
|
|
107
116
|
content.push({ line: fit(theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 1))), width) });
|
|
@@ -110,7 +119,11 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
|
|
|
110
119
|
const marker = multi ? (checked.has(at) ? "☑" : "☐") : (active ? "●" : "○");
|
|
111
120
|
const number = String(at + 1).padStart(2, " ") + ".";
|
|
112
121
|
content.push({ target: at, line: fit(theme.fg(active ? "accent" : "muted", "│ " + (active ? "❯ " : " ") + marker + " " + number + " " + option.label), width) });
|
|
113
|
-
if (option.description
|
|
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
|
+
}
|
|
114
127
|
});
|
|
115
128
|
actions.forEach((action, at) => {
|
|
116
129
|
const row = options.length + at;
|
|
@@ -119,24 +132,36 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
|
|
|
119
132
|
const chip = "[ " + action.icon + " " + label + " ]";
|
|
120
133
|
content.push({ target: row, line: fit(theme.fg(active ? "accent" : "dim", "│ " + (active ? "❯ " : " ") + chip), width) });
|
|
121
134
|
});
|
|
122
|
-
|
|
123
|
-
|
|
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) {
|
|
124
138
|
const target = content.findIndex((row) => row.target === selected);
|
|
125
139
|
if (target < scroll) scroll = target;
|
|
126
140
|
else if (target >= scroll + page) scroll = target - page + 1;
|
|
127
141
|
lastSelected = selected;
|
|
142
|
+
reveal = false;
|
|
128
143
|
}
|
|
129
144
|
scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - page)));
|
|
130
145
|
content.slice(scroll, scroll + page).forEach((row) => {
|
|
131
146
|
if (row.target !== undefined) hitRows[lines.length] = row.target;
|
|
132
147
|
lines.push(row.line);
|
|
133
148
|
});
|
|
134
|
-
const hint = multi ? `${checked.size}/${options.length} checked · ↑↓
|
|
149
|
+
const hint = multi ? `${checked.size}/${options.length} checked · ↑↓ · Space toggle · Enter · a all · Esc` : "↑↓ choose · Enter · Pg/wheel scroll · Esc";
|
|
135
150
|
lines.push(fit(theme.fg("dim", `╰─ ${scroll + 1}–${Math.min(scroll + page, content.length)}/${content.length} · ${hint}`), width));
|
|
136
|
-
|
|
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));
|
|
137
158
|
}
|
|
138
159
|
};
|
|
139
|
-
}
|
|
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); }
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
export async function askOne(ctx: ExtensionContext, question: AskQuestion, index = 0, total = 1, signal?: AbortSignal): Promise<AskAnswer> {
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
48
|
-
if (matchesKey(data, Key.
|
|
49
|
-
if (matchesKey(data, Key.
|
|
50
|
-
if (matchesKey(data, Key.
|
|
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
|
|
56
|
-
if (
|
|
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
|
-
...
|
|
67
|
-
fit(theme.fg("dim",
|
|
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
|
|
112
|
+
return popup<TodoAction | undefined>(ctx, (tui, theme, _keys, done) => {
|
|
80
113
|
let selected = 0;
|
|
81
|
-
const
|
|
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
|
-
|
|
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 <=
|
|
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
|
-
|
|
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 +
|
|
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", "╰─
|
|
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
|
|
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
|
-
}, {
|
|
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
|
|
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
|
-
}, {
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
-
}
|
|
149
|
+
});
|
|
149
150
|
}
|
package/src/popup.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
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
|
+
|
|
12
|
+
type Factory<T> = (tui: TUI, theme: Theme, keys: KeybindingsManager, done: (result: T) => void) => Component & { dispose?(): void };
|
|
13
|
+
export interface PopupOptions {
|
|
14
|
+
/** Views with their own search/editor keep ownership of printable keys. */
|
|
15
|
+
filter?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Common containment for every jar popup. Oversized chrome remains reachable, not silently clipped by Pi. */
|
|
19
|
+
export function popup<T>(ctx: ExtensionContext, factory: Factory<T>, options: PopupOptions = {}): Promise<T> {
|
|
20
|
+
return ctx.ui.custom<T>((tui, theme, keys, done) => {
|
|
21
|
+
const child = factory(tui, theme, keys, done);
|
|
22
|
+
const input = new Input({ prompt: "/ ", placeholder: "Filter popup content" });
|
|
23
|
+
let searching = false;
|
|
24
|
+
let scroll = 0;
|
|
25
|
+
let height = 24;
|
|
26
|
+
let width = 80;
|
|
27
|
+
let source: string[] = [];
|
|
28
|
+
let hits: number[] = [];
|
|
29
|
+
let shown: number[] = [];
|
|
30
|
+
let follow = false;
|
|
31
|
+
const page = () => Math.max(1, height - 2);
|
|
32
|
+
const move = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, hits.length - page()), scroll + delta)); };
|
|
33
|
+
input.onEscape = () => { searching = false; input.focused = false; input.setValue(""); scroll = 0; };
|
|
34
|
+
input.onSubmit = () => {
|
|
35
|
+
// Return to the original view, never activate a hidden selection on Enter.
|
|
36
|
+
scroll = hits[scroll] ?? 0;
|
|
37
|
+
searching = false; input.focused = false; input.setValue("");
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
get focused() { return searching ? input.focused : (child as Component & { focused?: boolean }).focused ?? false; },
|
|
41
|
+
set focused(value: boolean) { input.focused = searching && value; (child as Component & { focused?: boolean }).focused = value; },
|
|
42
|
+
invalidate() { child.invalidate(); input.invalidate(); },
|
|
43
|
+
dispose() { child.dispose?.(); },
|
|
44
|
+
handleInput(data: string) {
|
|
45
|
+
if (searching) {
|
|
46
|
+
if (matchesKey(data, Key.up)) move(-1);
|
|
47
|
+
else if (matchesKey(data, Key.down)) move(1);
|
|
48
|
+
else if (matchesKey(data, Key.pageUp)) move(-page());
|
|
49
|
+
else if (matchesKey(data, Key.pageDown)) move(page());
|
|
50
|
+
else { const before = input.getValue(); input.handleInput(data); if (input.getValue() !== before) scroll = 0; }
|
|
51
|
+
} else if (options.filter !== false && data === "/") {
|
|
52
|
+
searching = true; input.focused = true; scroll = 0;
|
|
53
|
+
} else if (matchesKey(data, Key.ctrl("pageUp"))) move(-page());
|
|
54
|
+
else if (matchesKey(data, Key.ctrl("pageDown"))) move(page());
|
|
55
|
+
else { child.handleInput?.(data); follow = true; }
|
|
56
|
+
tui.requestRender();
|
|
57
|
+
},
|
|
58
|
+
handleMouse(event) {
|
|
59
|
+
if (event.type === "wheel" && event.wheelDelta && (searching || source.length > height)) {
|
|
60
|
+
move(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true };
|
|
61
|
+
}
|
|
62
|
+
if (event.type === "wheel") return child.handleMouse?.(event);
|
|
63
|
+
const original = shown[event.y];
|
|
64
|
+
if (original === undefined || original < 0) return;
|
|
65
|
+
return child.handleMouse?.({ ...event, y: original });
|
|
66
|
+
},
|
|
67
|
+
render(available) {
|
|
68
|
+
width = Math.max(1, available);
|
|
69
|
+
height = Math.max(1, tui.terminal?.rows ?? process.stdout.rows ?? 24);
|
|
70
|
+
source = child.render(width);
|
|
71
|
+
const terms = input.getValue().trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
72
|
+
hits = source.flatMap((line, index) => {
|
|
73
|
+
const text = stripTerminalSequences(line).toLocaleLowerCase();
|
|
74
|
+
return !searching || (/[\p{L}\p{N}]/u.test(text) && terms.every((term) => text.includes(term))) ? [index] : [];
|
|
75
|
+
});
|
|
76
|
+
if (!searching && source.length <= height) {
|
|
77
|
+
scroll = 0; shown = hits; follow = false; return source.map((line) => truncateToWidth(line, width));
|
|
78
|
+
}
|
|
79
|
+
if (follow && !searching) {
|
|
80
|
+
const selected = source.findIndex((line) => stripTerminalSequences(line).includes("❯"));
|
|
81
|
+
if (selected >= 0) {
|
|
82
|
+
if (selected < scroll) scroll = selected;
|
|
83
|
+
if (selected >= scroll + page()) scroll = selected - page() + 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
follow = false;
|
|
87
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, hits.length - page())));
|
|
88
|
+
const window = hits.slice(scroll, scroll + page());
|
|
89
|
+
const body = window.length ? window.map((at) => source[at]!) : [theme.fg("dim", "No matches")];
|
|
90
|
+
const header = searching ? input.render(width)[0]! : theme.fg("dim", "↑↓ in view · Ctrl+PgUp/PgDn / wheel scroll popup" + (options.filter !== false ? " · / filter" : ""));
|
|
91
|
+
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" : ""));
|
|
92
|
+
if (height <= 2) {
|
|
93
|
+
shown = [...window.slice(0, 1), -1];
|
|
94
|
+
return [body[0]!, footer].slice(0, height).map((line) => truncateToWidth(line, width));
|
|
95
|
+
}
|
|
96
|
+
shown = [-1, ...window, -1];
|
|
97
|
+
// Header/footer have no child hit target, even on a one-row terminal.
|
|
98
|
+
shown[0] = -1;
|
|
99
|
+
return [header, ...body, footer].slice(0, height).map((line) => truncateToWidth(line, width));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}, { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0, visible: () => priorityPopups === 0 } });
|
|
103
|
+
}
|
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
|
|
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
|
|
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;
|
package/src/prompt-search.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 { 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
|
|
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
|
-
|
|
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
|
-
}, {
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
}, {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
}, {
|
|
155
|
+
}, { filter: false });
|
|
155
156
|
}
|
package/src/settings-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 { 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
|
|
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,
|
|
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(
|
|
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
|
+
}
|