pi-async-bash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts ADDED
@@ -0,0 +1,311 @@
1
+ /** Async Bash task manager for `/bash-async-list`. */
2
+
3
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Key, matchesKey, SelectList, Text, type Component, type KeybindingsManager, type SelectItem, type TUI } from "@earendil-works/pi-tui";
5
+ import type { Job, UiContext } from "./types.ts";
6
+ import { OUTPUT_PREVIEW_CHARS, PREVIEW_CHARS } from "./types.ts";
7
+ import type { BackgroundRegistry } from "./state.ts";
8
+ import { formatDuration, jobLabel } from "./format.ts";
9
+ import { terminateJobSilently } from "./lifecycle.ts";
10
+ import { readLogTail, renderSidebar } from "./registry.ts";
11
+
12
+ type TaskSelection =
13
+ | { action: "output"; job: Job; selectedJobId: string }
14
+ | { action: "reload"; selectedJobId?: string }
15
+ | { action: "close" };
16
+
17
+ export async function openBgListPanel(
18
+ reg: BackgroundRegistry,
19
+ ctx: UiContext,
20
+ ): Promise<void> {
21
+ let selectedJobId: string | undefined;
22
+
23
+ while (true) {
24
+ const selection = await selectJob(reg, ctx, selectedJobId);
25
+ if (selection.action === "close") return;
26
+ selectedJobId = selection.selectedJobId;
27
+ if (selection.action === "reload") continue;
28
+ await showOutput(selection.job, ctx);
29
+ }
30
+ }
31
+
32
+ async function selectJob(
33
+ reg: BackgroundRegistry,
34
+ ctx: UiContext,
35
+ selectedJobId: string | undefined,
36
+ ): Promise<TaskSelection> {
37
+ const jobs = getJobList(reg);
38
+ if (jobs.length === 0) {
39
+ return ctx.ui.custom?.<TaskSelection>((tui, theme, keybindings, done) =>
40
+ new EmptyTaskListComponent(tui, theme, keybindings, done),
41
+ ) ?? Promise.resolve({ action: "close" });
42
+ }
43
+
44
+ return ctx.ui.custom?.<TaskSelection>((tui, theme, keybindings, done) =>
45
+ new TaskListComponent(reg, ctx, jobs, selectedJobId, tui, theme, keybindings, done),
46
+ ) ?? Promise.resolve({ action: "close" });
47
+ }
48
+
49
+ export class TaskListComponent extends Container {
50
+ private readonly jobs: Job[];
51
+ private readonly jobsById: Map<string, Job>;
52
+ private readonly header: Text;
53
+ private readonly footer: Text;
54
+ private readonly list: SelectList;
55
+ private confirmingJob: Job | undefined;
56
+ private completed = false;
57
+ private readonly reg: BackgroundRegistry;
58
+ private readonly ctx: UiContext;
59
+ private readonly tui: TUI;
60
+ private readonly theme: Theme;
61
+ private readonly keybindings: KeybindingsManager;
62
+ private readonly done: (selection: TaskSelection) => void;
63
+
64
+ constructor(
65
+ reg: BackgroundRegistry,
66
+ ctx: UiContext,
67
+ jobs: Job[],
68
+ selectedJobId: string | undefined,
69
+ tui: TUI,
70
+ theme: Theme,
71
+ keybindings: KeybindingsManager,
72
+ done: (selection: TaskSelection) => void,
73
+ ) {
74
+ super();
75
+ this.reg = reg;
76
+ this.ctx = ctx;
77
+ this.tui = tui;
78
+ this.theme = theme;
79
+ this.keybindings = keybindings;
80
+ this.done = done;
81
+ this.jobs = jobs;
82
+ this.jobsById = new Map(jobs.map((job) => [job.id, job]));
83
+ this.header = new Text(this.renderHeader(jobs), 1, 0);
84
+ this.footer = new Text("", 1, 0);
85
+ this.list = new SelectList(jobs.map(jobToSelectItem), Math.min(jobs.length, 10), {
86
+ selectedPrefix: (text) => this.theme.fg("accent", text),
87
+ selectedText: (text) => this.theme.fg("accent", text),
88
+ description: (text) => this.theme.fg("muted", text),
89
+ scrollInfo: (text) => this.theme.fg("dim", text),
90
+ noMatch: (text) => this.theme.fg("warning", text),
91
+ });
92
+ this.list.setSelectedIndex(Math.max(0, jobs.findIndex((job) => job.id === selectedJobId)));
93
+ this.list.onSelect = (item) => {
94
+ const job = this.jobsById.get(item.value);
95
+ if (job) this.done({ action: "output", job, selectedJobId: job.id });
96
+ };
97
+ this.list.onCancel = () => this.done({ action: "close" });
98
+
99
+ this.addChild(new DynamicBorder((text: string) => this.theme.fg("accent", text)));
100
+ this.addChild(this.header);
101
+ this.addChild(this.list);
102
+ this.addChild(this.footer);
103
+ this.addChild(new DynamicBorder((text: string) => this.theme.fg("accent", text)));
104
+ this.updateFooter();
105
+ }
106
+
107
+ handleInput(data: string): void {
108
+ if (this.completed) return;
109
+ if (this.confirmingJob) {
110
+ this.handleConfirmation(data);
111
+ return;
112
+ }
113
+
114
+ if (matchesKey(data, Key.ctrl("x"))) {
115
+ this.requestKill();
116
+ return;
117
+ }
118
+
119
+ if (matchesKey(data, "j")) {
120
+ this.moveSelection(1);
121
+ return;
122
+ }
123
+
124
+ if (matchesKey(data, "k")) {
125
+ this.moveSelection(-1);
126
+ return;
127
+ }
128
+
129
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
130
+ this.done({ action: "close" });
131
+ return;
132
+ }
133
+
134
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
135
+ const item = this.list.getSelectedItem();
136
+ const job = item ? this.jobsById.get(item.value) : undefined;
137
+ if (job) this.done({ action: "output", job, selectedJobId: job.id });
138
+ return;
139
+ }
140
+
141
+ this.list.handleInput(data);
142
+ this.tui.requestRender();
143
+ }
144
+
145
+ private moveSelection(delta: number): void {
146
+ const selected = this.list.getSelectedItem();
147
+ const currentIndex = selected ? this.jobs.findIndex((job) => job.id === selected.value) : 0;
148
+ const nextIndex = (currentIndex + delta + this.jobs.length) % this.jobs.length;
149
+ this.list.setSelectedIndex(nextIndex);
150
+ this.tui.requestRender();
151
+ }
152
+
153
+ private handleConfirmation(data: string): void {
154
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
155
+ const job = this.confirmingJob;
156
+ if (job) this.kill(job);
157
+ return;
158
+ }
159
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
160
+ this.confirmingJob = undefined;
161
+ this.updateFooter();
162
+ this.tui.requestRender();
163
+ }
164
+ }
165
+
166
+ private requestKill(): void {
167
+ const item = this.list.getSelectedItem();
168
+ const job = item ? this.jobsById.get(item.value) : undefined;
169
+ if (!job || job.status !== "running") {
170
+ this.ctx.ui.notify("Task is not running", "warning");
171
+ return;
172
+ }
173
+
174
+ this.confirmingJob = job;
175
+ this.updateFooter();
176
+ this.tui.requestRender();
177
+ }
178
+
179
+ private kill(job: Job): void {
180
+ terminateJobSilently(this.reg, job);
181
+ renderSidebar(this.reg, this.ctx);
182
+ this.ctx.ui.notify(`Killed ${jobLabel(job)}`, "info");
183
+ this.completed = true;
184
+ this.done({ action: "reload", selectedJobId: job.id });
185
+ }
186
+
187
+ private renderHeader(jobs: Job[]): string {
188
+ const running = jobs.filter((job) => job.status === "running").length;
189
+ return this.theme.fg(
190
+ "accent",
191
+ this.theme.bold(`Async Bash Tasks · ${jobs.length} total · ${running} running`),
192
+ );
193
+ }
194
+
195
+ private updateFooter(): void {
196
+ if (this.confirmingJob) {
197
+ this.footer.setText(this.theme.fg(
198
+ "warning",
199
+ `Kill ${jobLabel(this.confirmingJob)}? ${formatKeyHint(this.keybindings, "tui.select.confirm", "confirm")} · ${formatKeyHint(this.keybindings, "tui.select.cancel", "cancel")}`,
200
+ ));
201
+ return;
202
+ }
203
+
204
+ this.footer.setText(this.theme.fg(
205
+ "dim",
206
+ `${formatKeyHint(this.keybindings, "tui.select.confirm", "output")} · Ctrl+x kill · ${formatKeyHint(this.keybindings, "tui.select.cancel", "close")}`,
207
+ ));
208
+ }
209
+ }
210
+
211
+ class EmptyTaskListComponent extends Container {
212
+ private readonly tui: TUI;
213
+ private readonly theme: Theme;
214
+ private readonly keybindings: KeybindingsManager;
215
+ private readonly done: (selection: TaskSelection) => void;
216
+
217
+ constructor(
218
+ tui: TUI,
219
+ theme: Theme,
220
+ keybindings: KeybindingsManager,
221
+ done: (selection: TaskSelection) => void,
222
+ ) {
223
+ super();
224
+ this.tui = tui;
225
+ this.theme = theme;
226
+ this.keybindings = keybindings;
227
+ this.done = done;
228
+ this.addChild(new DynamicBorder((text: string) => this.theme.fg("accent", text)));
229
+ this.addChild(new Text(this.theme.fg("accent", this.theme.bold("Async Bash Tasks")), 1, 0));
230
+ this.addChild(new Text(this.theme.fg("muted", "No asynchronous Bash tasks"), 1, 0));
231
+ this.addChild(new Text(
232
+ this.theme.fg("dim", formatKeyHint(this.keybindings, "tui.select.cancel", "close")),
233
+ 1,
234
+ 0,
235
+ ));
236
+ this.addChild(new DynamicBorder((text: string) => this.theme.fg("accent", text)));
237
+ }
238
+
239
+ handleInput(data: string): void {
240
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
241
+ this.done({ action: "close" });
242
+ return;
243
+ }
244
+ this.tui.requestRender();
245
+ }
246
+ }
247
+
248
+ function formatKeyHint(
249
+ keybindings: KeybindingsManager,
250
+ binding: "tui.select.confirm" | "tui.select.cancel",
251
+ action: string,
252
+ ): string {
253
+ return `${keybindings.getKeys(binding).map(formatKey).join("/")} ${action}`;
254
+ }
255
+
256
+ function formatKey(key: string): string {
257
+ if (key === "escape") return "Esc";
258
+ if (key === "enter") return "Enter";
259
+ const hasModifier = key.includes("+");
260
+ return key.split("+").map((part) => {
261
+ if (part === "ctrl") return "Ctrl";
262
+ if (part === "alt") return "Alt";
263
+ if (part === "shift") return "Shift";
264
+ if (part === "super") return "Super";
265
+ if (part.length === 1) return hasModifier ? part.toLowerCase() : part.toUpperCase();
266
+ return part;
267
+ }).join("+");
268
+ }
269
+
270
+ function jobToSelectItem(job: Job): SelectItem {
271
+ const icon = statusIcon(job);
272
+ const duration = formatDuration(Date.now() - job.startTime);
273
+ const label = job.name ? `${job.name} (${job.id})` : job.id;
274
+ const status = job.status === "running" ? `running (${duration})` : job.status;
275
+ const exitCode = job.exitCode === undefined ? "" : ` · exit ${job.exitCode}`;
276
+ return {
277
+ value: job.id,
278
+ label: `${icon} ${label}`,
279
+ description: `${job.command.slice(0, PREVIEW_CHARS.taskList)} · ${status}${exitCode}`,
280
+ };
281
+ }
282
+
283
+ async function showOutput(job: Job, ctx: UiContext): Promise<void> {
284
+ const output = readLogTail(job, OUTPUT_PREVIEW_CHARS);
285
+ const duration = formatDuration(Date.now() - job.startTime);
286
+ const exitLine = job.exitCode === undefined ? "" : `\nExit code: ${job.exitCode}`;
287
+ await ctx.ui.editor(
288
+ `${statusIcon(job)} ${jobLabel(job)}`,
289
+ `Command: ${job.command}\n` +
290
+ `PID: ${job.pid} · Started: ${new Date(job.startTime).toLocaleString()}\n` +
291
+ `Duration: ${duration} · Status: ${job.status}${exitLine}\n` +
292
+ `Log: ${job.logPath}\n\n--- OUTPUT ---\n${output}\n\nEsc returns to the task list`,
293
+ );
294
+ }
295
+
296
+ function getJobList(reg: BackgroundRegistry): Job[] {
297
+ const jobs = Array.from(reg.jobs.values());
298
+ const running = jobs.filter((job) => job.status === "running").sort((left, right) => right.startTime - left.startTime);
299
+ const terminal = jobs.filter((job) => job.status !== "running").sort((left, right) => right.startTime - left.startTime);
300
+ return [...running, ...terminal];
301
+ }
302
+
303
+ function statusIcon(job: Job): string {
304
+ switch (job.status) {
305
+ case "pending": return "◌";
306
+ case "running": return "▶";
307
+ case "completed": return "✓";
308
+ case "failed": return "✗";
309
+ case "killed": return "✗";
310
+ }
311
+ }