@oisin-ee/oisinpi 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @oisin-ee/oisinpi
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`0170caa`](https://github.com/oisin-ee/oisinpi/commit/0170caa9c59c4e3d71409627b14925cefcc68679) Thanks [@momo-momokaya[bot]](https://github.com/momo-momokaya%5Bbot%5D)! - Bundle pi-wrangle 0.12.2. Show the current task and named agents and jobs above the editor. Alt+W and `/work` expand the same widget in place, without a popup or editor replacement.
8
+ - Updated dependencies []:
9
+ - @oisin-ee/pi-guardrails@0.5.1
10
+ - @oisin-ee/pi-prewalk@0.5.1
11
+ - @oisin-ee/pi-todo@0.5.1
12
+
3
13
  ## 0.5.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -6,7 +6,7 @@ Oisin's [pi](https://pi.dev) packages in one install, at versions tested togethe
6
6
  | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
7
7
  | [`@oisin-ee/pi-prewalk`](https://www.npmjs.com/package/@oisin-ee/pi-prewalk) | A frontier model plans and makes the first edit; a cheaper model finishes. |
8
8
  | [`@oisin-ee/pi-todo`](https://www.npmjs.com/package/@oisin-ee/pi-todo) | The `todo` tool, which also opens the prewalk gate. |
9
- | [`@oisin-ee/pi-wrangle`](https://www.npmjs.com/package/@oisin-ee/pi-wrangle) | The shared `WORK` strip, `/work`, `/wrangle`, and the agent/job inbox. |
9
+ | [`@oisin-ee/pi-wrangle`](https://www.npmjs.com/package/@oisin-ee/pi-wrangle) | The inline `WORK` widget, `/work`, `/wrangle`, and the agent/job inbox. |
10
10
  | [`@oisin-ee/pi-guardrails`](https://www.npmjs.com/package/@oisin-ee/pi-guardrails) | Git safety, the wait gate, and the agent-typing gate on every tool call. |
11
11
 
12
12
  ```bash
@@ -17,6 +17,8 @@ The collection bundles the four packages in its tarball and adds no code. No mem
17
17
  installs by itself. pi-wrangle needs the `wrangle` binary at the same version
18
18
  ([oisin-ee/wrangle](https://github.com/oisin-ee/wrangle)).
19
19
 
20
- `/work` shows read-only todos for the active Pi branch and agents/jobs for the Herdr owner pane.
20
+ The compact widget shows the current task and named agents and jobs above the editor.
21
+ Alt+W or `/work` expands that widget in place, without a popup. Typing stays in the editor.
22
+ Todos follow the active Pi branch. Agents and jobs follow the Herdr owner pane.
21
23
  Use Wrangle `run`, `read`, and `stop` for background work. The collection does not require `pi-background-tasks`.
22
24
  Standalone pi-todo keeps its own widget when Wrangle does not claim the shared display.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-guardrails",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Agent guardrails for pi, Claude Code, and Codex: Git safety, no waiting, no typing into other agents, GitOps-only clusters.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-prewalk",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A one-shot planner-to-executor handoff for pi, with branch-backed Rust rules.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-todo",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A todo tool for pi with a Rust core: one active task, state on the session branch.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -466,8 +466,8 @@ The caller is the harness's `HERDR_PANE_ID`. Pi registers the server from its ex
466
466
 
467
467
  The extension adds host status before each prompt, registers the tools, and draws:
468
468
 
469
- - **The `WORK` strip** above the editor: one row for todo progress, agents, jobs, and attention. Agent/job data refreshes every ten seconds. Compatible pi-todo snapshots update immediately. Empty providers hide the strip; unavailable providers show an error instead of false zero counts.
470
- - **`/work`**, a bounded overlay with All, Todos, Agents, and Jobs views. Tab changes views; Enter opens details. Todos are read-only. Explicit actions stop jobs, cancel queued tickets, or message agents through the inbox. Stop and cancel require confirmation. Escape returns to the unchanged editor draft. Completed job history stays collapsed until selected.
469
+ - **The `WORK` widget** above the editor shows the current task and named agents and jobs. Agent/job data refreshes every ten seconds. Compatible pi-todo snapshots update immediately. Empty providers hide the widget. Unavailable providers show an error.
470
+ - **Alt+W or `/work`** expands or collapses that widget in place, without a popup or editor replacement. Alt+J/K moves selection, Alt+] changes views, and Alt+I shows inline details. Typing stays in the editor. Alt+S stops jobs, Alt+C cancels queued work, and Alt+M sends agent messages. Stop and cancel require confirmation. Completed job history starts collapsed.
471
471
  - **`/wrangle`**, the control panel, with four tabs: Status (hosts with their retry state, then your rows; Enter on a host reads it again now), Hosts (reconnect, rename, enable, disable, add), Profiles (Enter edits `prompt.md`), and Inbox. The widget steps aside while the panel is open.
472
472
  - **The inbox reader** and a renderer that shows each record's kind, sender, and text, folded past twelve lines.
473
473
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-wrangle",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Pi extension for wrangle: the board, the /wrangle panel, the inbox reader, and the wrangle tools. Needs the wrangle binary of the same version.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,13 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { Box, Text, Key } from "@earendil-works/pi-tui";
4
4
  import { type Board, clean, parseBoard } from "./board.ts";
5
5
  import { WorkTodo } from "./work-todo.ts";
6
6
  import { workStrip, workText, type WorkState } from "./work.ts";
7
7
  import { type Details, InboxReader, MESSAGE_TYPE } from "./inbox.ts";
8
8
  import { openPanel } from "./panel.ts";
9
9
  import { loadJobHistory, type JobHistory } from "./work-history.ts";
10
- import { WorkPanel, runWorkAction, showWorkDetail, type WorkAction, type WorkCursor, type WorkData, type WorkTab } from "./work-panel.ts";
10
+ import { WorkPanel, WorkDetail, runWorkAction, type WorkAction, type WorkCursor, type WorkData, type WorkTab } from "./work-panel.ts";
11
11
 
12
12
  const REFRESH_MS = 10_000;
13
13
 
@@ -64,7 +64,9 @@ export default function wrangle(pi: ExtensionAPI) {
64
64
  let workOpen = false;
65
65
  let history: JobHistory = { jobs: [] };
66
66
  let workPanel: WorkPanel | undefined;
67
- let closeWork: (() => void) | undefined;
67
+ let workDetail: WorkDetail | undefined;
68
+ let detail: { title: string; body: string } | undefined;
69
+ let cursor: WorkCursor = { tab: "all", selected: undefined, history: false };
68
70
  let workAbort: AbortController | undefined;
69
71
  let context: ExtensionContext | undefined;
70
72
  const todos = new WorkTodo(pi.events, () => { if (context) draw(context); }, () => {
@@ -77,44 +79,52 @@ export default function wrangle(pi: ExtensionAPI) {
77
79
 
78
80
  function workData(): WorkData { return { ...state(), history }; }
79
81
 
82
+ function collapseWork(ctx: ExtensionContext): void {
83
+ workAbort?.abort();
84
+ workAbort = undefined;
85
+ workOpen = false;
86
+ workPanel = undefined;
87
+ workDetail = undefined;
88
+ detail = undefined;
89
+ history = { jobs: [] };
90
+ draw(ctx);
91
+ }
92
+
80
93
  async function openWork(ctx: ExtensionContext, tab: WorkTab): Promise<void> {
81
94
  if (ctx.mode !== "tui") { ctx.ui.notify(workText(state()), "info"); return; }
82
- if (workOpen || panelOpen) return;
95
+ if (panelOpen) return;
83
96
  workOpen = true;
97
+ detail = undefined;
98
+ workDetail = undefined;
99
+ cursor = { tab, selected: undefined, history: false };
100
+ workPanel = undefined;
101
+ const current = generation;
102
+ draw(ctx);
103
+ // Draw immediately; remote history must not delay expansion or typing.
104
+ if (refresh) await refresh;
105
+ if (current === generation && workOpen) await update(ctx);
106
+ }
107
+
108
+ async function act(ctx: ExtensionContext, action: WorkAction | undefined): Promise<void> {
109
+ if (!action) { collapseWork(ctx); return; }
110
+ if (workAbort) return;
84
111
  const interaction = new AbortController();
85
112
  workAbort = interaction;
86
113
  const current = generation;
87
- let cursor: WorkCursor = { tab, selected: undefined, history: false };
88
114
  try {
89
- // An older refresh may predate history demand; finish it before the panel refresh.
90
- if (refresh) await refresh;
91
- if (interaction.signal.aborted || current !== generation) return;
115
+ const body = await runWorkAction(action, ctx, exec, interaction.signal);
116
+ if (current !== generation || interaction.signal.aborted) return;
117
+ if (body !== undefined) { detail = { title: action.entry.label, body }; workDetail = undefined; }
118
+ draw(ctx);
92
119
  await update(ctx);
93
- while (current === generation && workOpen) {
94
- const action = await ctx.ui.custom<WorkAction | undefined>((tui, theme, keys, done) => {
95
- closeWork = () => done(undefined);
96
- workPanel = new WorkPanel(workData(), cursor, theme, keys, done, () => tui.requestRender(), () => tui.terminal.rows);
97
- return workPanel;
98
- }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
99
- workPanel = undefined;
100
- closeWork = undefined;
101
- if (!action || current !== generation) break;
102
- cursor = action.cursor;
103
- try {
104
- const detail = await runWorkAction(action, ctx, exec, interaction.signal);
105
- if (detail !== undefined && current === generation) await showWorkDetail(ctx, action.entry.label, detail, interaction.signal);
106
- } catch (error) {
107
- if (current === generation) await showWorkDetail(ctx, "Work action unavailable", String(error), interaction.signal);
108
- }
109
- if (current === generation) await update(ctx);
120
+ } catch (error) {
121
+ if (current === generation && !interaction.signal.aborted) {
122
+ detail = { title: "Work action unavailable", body: String(error) };
123
+ workDetail = undefined;
124
+ draw(ctx);
110
125
  }
111
126
  } finally {
112
- interaction.abort();
113
127
  if (workAbort === interaction) workAbort = undefined;
114
- workPanel = undefined;
115
- closeWork = undefined;
116
- workOpen = false;
117
- history = { jobs: [] };
118
128
  }
119
129
  }
120
130
 
@@ -122,10 +132,19 @@ export default function wrangle(pi: ExtensionAPI) {
122
132
  if (ctx.mode !== "tui") return;
123
133
  workPanel?.update(workData());
124
134
  const data = state();
125
- ctx.ui.setWidget("wrangle", panelOpen || (!data.board.rows.length && !data.todo?.tasks.length && !data.error) ? undefined : (_tui, theme) => ({
126
- render: (width: number) => workStrip(data, width, theme),
127
- invalidate: () => {},
128
- }), { placement: "aboveEditor" });
135
+ ctx.ui.setWidget("wrangle", panelOpen || (!workOpen && !data.board.rows.length && !data.todo?.tasks.length && !data.error) ? undefined : (tui, theme) => {
136
+ if (workOpen) {
137
+ if (detail) {
138
+ workDetail ??= new WorkDetail(detail.title, detail.body, theme, () => tui.terminal.rows, () => tui.requestRender());
139
+ workDetail.setTheme(theme);
140
+ return workDetail;
141
+ }
142
+ workDetail = undefined;
143
+ workPanel = new WorkPanel(workData(), cursor, theme, action => { void act(ctx, action); }, () => tui.requestRender(), () => tui.terminal.rows);
144
+ return workPanel;
145
+ }
146
+ return { render: (width: number) => workStrip(data, width, theme), invalidate() {} };
147
+ }, { placement: "aboveEditor" });
129
148
  }
130
149
 
131
150
  function update(ctx: ExtensionContext): Promise<void> {
@@ -143,7 +162,7 @@ export default function wrangle(pi: ExtensionAPI) {
143
162
  if (workOpen) {
144
163
  const loaded = await loadJobHistory(exec, owner);
145
164
  if (current !== generation) return;
146
- history = loaded;
165
+ if (workOpen) history = loaded;
147
166
  }
148
167
  draw(ctx);
149
168
  } catch (error) {
@@ -162,7 +181,12 @@ export default function wrangle(pi: ExtensionAPI) {
162
181
  if (timer) clearInterval(timer);
163
182
  generation++;
164
183
  workAbort?.abort();
165
- closeWork?.();
184
+ workAbort = undefined;
185
+ workOpen = false;
186
+ workPanel = undefined;
187
+ workDetail = undefined;
188
+ detail = undefined;
189
+ history = { jobs: [] };
166
190
  context = ctx;
167
191
  board = { hosts: [], rows: [] };
168
192
  failure = owner ? undefined : "Wrangle needs a Herdr owner pane";
@@ -182,7 +206,11 @@ export default function wrangle(pi: ExtensionAPI) {
182
206
  pi.on("session_shutdown", (_event, ctx) => {
183
207
  generation++;
184
208
  workAbort?.abort();
185
- closeWork?.();
209
+ workAbort = undefined;
210
+ workOpen = false;
211
+ workPanel = undefined;
212
+ workDetail = undefined;
213
+ detail = undefined;
186
214
  if (timer) clearInterval(timer);
187
215
  timer = undefined;
188
216
  inbox.stop();
@@ -203,12 +231,29 @@ export default function wrangle(pi: ExtensionAPI) {
203
231
  return;
204
232
  }
205
233
  });
234
+ async function toggleWork(ctx: ExtensionContext): Promise<void> {
235
+ if (workOpen) collapseWork(ctx);
236
+ else await openWork(ctx, "all");
237
+ }
206
238
  pi.registerCommand("work", {
207
- description: "Read todos on this branch and this owner's agents and jobs",
208
- handler: async (_args, ctx) => {
209
- await openWork(ctx, "all");
210
- },
239
+ description: "Expand or collapse work above the editor (Alt+W)",
240
+ handler: async (_args, ctx) => { await toggleWork(ctx); },
211
241
  });
242
+ pi.registerShortcut(Key.alt("w"), { description: "Expand or collapse work above the editor", handler: toggleWork });
243
+ for (const [key, input, description] of [
244
+ ["j", "\u001b[B", "Next work item / scroll down"], ["k", "\u001b[A", "Previous work item / scroll up"],
245
+ ["]", "\t", "Next work view"], ["i", "\r", "Inspect work / return to list"],
246
+ ["s", "s", "Stop selected job"], ["c", "c", "Cancel selected queued work"],
247
+ ["m", "m", "Message selected agent"], ["r", "r", "Refresh work"],
248
+ ] as const) {
249
+ pi.registerShortcut(Key.alt(key), { description, handler: async (_ctx) => {
250
+ if (!workOpen || panelOpen || workAbort || !context) return;
251
+ if (detail) {
252
+ if (key === "i") { detail = undefined; draw(context); }
253
+ else if (key === "j" || key === "k") workDetail?.handleInput(input);
254
+ } else workPanel?.handleInput(input);
255
+ } });
256
+ }
212
257
  pi.registerCommand("wrangle", {
213
258
  description: "Hosts, your children and jobs, profiles, and messages",
214
259
  handler: async (_args, ctx) => {
@@ -216,7 +261,7 @@ export default function wrangle(pi: ExtensionAPI) {
216
261
  ctx.ui.notify(await exec(["board"]), "info");
217
262
  return;
218
263
  }
219
- if (workOpen || panelOpen) return;
264
+ if (panelOpen || workAbort) return;
220
265
  panelOpen = true;
221
266
  draw(ctx);
222
267
  try {
@@ -1,4 +1,4 @@
1
- import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, SelectList, Text, ScrollView, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { clean } from "./board.ts";
4
4
  import type { WorkState } from "./work.ts";
@@ -19,7 +19,7 @@ export interface WorkAction { action: "inspect" | "stop" | "cancel" | "send" | "
19
19
  export function workEntries(data: WorkData, tab: WorkTab, history: boolean): WorkEntry[] {
20
20
  const todos: WorkEntry[] = (data.todo?.tasks ?? []).map(task => ({ value: `todo:${task.content}`, label: `${task.status === "in_progress" ? "→" : task.status === "completed" ? "✓" : task.status === "abandoned" ? "−" : "·"} ${clean(task.content)}`, description: task.status, kind: "todo", detail: task.content }));
21
21
  const canonicalHost = (label: string) => data.history.hosts?.find(host => host.label === label)?.id ?? label;
22
- const rows: WorkEntry[] = data.board.rows.map(row => ({ value: `${row.kind}:${canonicalHost(row.host)}:${row.id}`, id: row.id, host: canonicalHost(row.host), ...(row.pane ? { pane: row.pane } : {}), kind: row.group === "waiting" ? "queued" : row.kind === "job" ? "job" : "agent", label: `${row.group === "attention" || row.group === "to_land" ? "!" : "●"} ${clean(row.name)}`, description: `${clean(row.state)} · ${clean(row.host)}`, running: row.group === "running" }));
22
+ const rows: WorkEntry[] = data.board.rows.map(row => ({ value: `${row.kind}:${canonicalHost(row.host)}:${row.id}`, id: row.id, host: canonicalHost(row.host), ...(row.pane ? { pane: row.pane } : {}), kind: row.group === "waiting" ? "queued" : row.kind === "job" ? "job" : "agent", label: `${row.group === "attention" || row.group === "to_land" ? "!" : "●"} ${clean(row.name)} · ${clean(row.state)}`, description: clean(row.host), running: row.group === "running" }));
23
23
  const visible = tab === "todos" ? todos : tab === "agents" ? rows.filter(row => row.value.startsWith("agent:")) : tab === "jobs" ? rows.filter(row => row.value.startsWith("job:")) : [...rows.filter(row => row.label.startsWith("!")), ...todos.filter(row => row.description === "in_progress"), ...rows.filter(row => !row.label.startsWith("!")), ...todos.filter(row => row.description === "pending")];
24
24
  if (tab === "all" || tab === "jobs") {
25
25
  const known = new Set(rows.map(row => row.value));
@@ -39,21 +39,21 @@ export class WorkPanel {
39
39
  private data: WorkData;
40
40
  readonly cursor: WorkCursor;
41
41
  private readonly theme: Theme;
42
- private readonly keys: KeybindingsManager;
43
42
  private readonly done: (action: WorkAction | undefined) => void;
44
43
  private readonly renderRequest: () => void;
45
44
  private readonly rows: () => number;
46
45
  private height = 0;
47
46
 
48
- constructor(data: WorkData, cursor: WorkCursor, theme: Theme, keys: KeybindingsManager, done: (action: WorkAction | undefined) => void, request: () => void, rows: () => number) {
49
- this.data = data; this.cursor = cursor; this.theme = theme; this.keys = keys; this.done = done; this.renderRequest = request; this.rows = rows;
47
+ constructor(data: WorkData, cursor: WorkCursor, theme: Theme, done: (action: WorkAction | undefined) => void, request: () => void, rows: () => number) {
48
+ this.data = data; this.cursor = cursor; this.theme = theme; this.done = done; this.renderRequest = request; this.rows = rows;
50
49
  this.rebuild();
51
50
  }
52
51
  update(data: WorkData): void { this.data = data; this.rebuild(); this.renderRequest(); }
52
+ private listHeight(): number { return Math.max(1, Math.min(12, Math.floor(this.rows() / 2) - 4)); }
53
53
  private rebuild(preserveSelection = true): void {
54
54
  if (preserveSelection) this.cursor.selected = this.list?.getSelectedItem()?.value ?? this.cursor.selected;
55
55
  this.entries = workEntries(this.data, this.cursor.tab, this.cursor.history);
56
- this.height = Math.max(1, Math.min(14, Math.floor(this.rows() * 0.8) - 5));
56
+ this.height = this.listHeight();
57
57
  this.list = new SelectList(this.entries, this.height, {
58
58
  selectedPrefix: text => this.theme.fg("accent", text), selectedText: text => this.theme.fg("accent", text),
59
59
  description: text => this.theme.fg("muted", text), scrollInfo: text => this.theme.fg("dim", text), noMatch: text => this.theme.fg("muted", text),
@@ -82,12 +82,13 @@ export class WorkPanel {
82
82
  else if (input === "c") this.act("cancel");
83
83
  else if (input === "m") this.act("send");
84
84
  else this.list.handleInput(input);
85
+ this.renderRequest();
85
86
  }
86
87
  invalidate(): void { this.list.invalidate(); }
87
88
  render(width: number): string[] {
88
- if (this.height !== Math.max(1, Math.min(14, Math.floor(this.rows() * 0.8) - 5))) this.rebuild();
89
+ if (this.height !== this.listHeight()) this.rebuild();
89
90
  const tabbar = tabs.map(tab => tab === this.cursor.tab ? `[${tab.toUpperCase()}]` : tab).join(" · ");
90
- return [this.theme.fg("accent", `WORK ${tabbar}`), this.theme.fg("dim", "Todos: Pi branch · Agents/jobs: Herdr owner pane"), ...this.list.render(width), this.theme.fg("dim", "Tab view · Enter inspect · s stop · c cancel · m message"), this.theme.fg("dim", `r refresh · ${this.keys.getKeys("tui.select.cancel").join("/")} close`)].map(line => truncateToWidth(line, width));
91
+ return [this.theme.fg("accent", `WORK ${tabbar}`), ...this.list.render(width), this.theme.fg("dim", "Alt+W collapse · Alt+J/K select · Alt+] view · Alt+I inspect"), this.theme.fg("dim", "Alt+S stop · Alt+C cancel · Alt+M message · Alt+R refresh")].map(line => truncateToWidth(line, width));
91
92
  }
92
93
  }
93
94
 
@@ -105,7 +106,7 @@ export async function inspectEntry(entry: WorkEntry, exec: WorkExec): Promise<st
105
106
  return hint + safeOutput(output);
106
107
  }
107
108
 
108
- /** Actions run after closing the list, so confirmation/input dialogs own focus. */
109
+ /** Explicit actions may open confirmation/input dialogs; the work widget stays inline. */
109
110
  export async function runWorkAction(action: WorkAction, ctx: ExtensionContext, exec: WorkExec, signal: AbortSignal): Promise<string | undefined> {
110
111
  if (signal.aborted) return undefined;
111
112
  const entry = action.entry;
@@ -119,29 +120,29 @@ export async function runWorkAction(action: WorkAction, ctx: ExtensionContext, e
119
120
  return undefined;
120
121
  }
121
122
 
122
- export async function showWorkDetail(ctx: ExtensionContext, title: string, body: string, signal: AbortSignal): Promise<void> {
123
- if (signal.aborted) return;
124
- await ctx.ui.custom<void>((tui, theme, keys, done) => {
125
- const abort = () => done();
126
- signal.addEventListener("abort", abort, { once: true });
127
- const text = new Text(safeOutput(body) || "No output yet.", 0, 0);
128
- const scroll = new ScrollView(text, { overscroll: "contain", scrollbar: "hidden" });
129
- return {
130
- dispose() { signal.removeEventListener("abort", abort); },
131
- render(width: number) {
132
- const height = Math.max(1, Math.min(24, Math.floor(tui.terminal.rows * 0.8) - 3));
133
- scroll.updateLayout(text.render(width).length, height, () => tui.requestRender());
134
- return [theme.fg("accent", truncateToWidth(title, width)), ...scroll.render(width).slice(scroll.scrollTop, scroll.scrollTop + height), theme.fg("dim", truncateToWidth("↑↓ / PageUp / PageDown scroll · Esc back", width))];
135
- },
136
- invalidate() { text.invalidate(); scroll.invalidate(); },
137
- handleInput(input: string) {
138
- if (keys.matches(input, "tui.select.cancel")) { done(); return; }
139
- if (matchesKey(input, Key.up)) scroll.scrollBy(-1);
140
- if (matchesKey(input, Key.down)) scroll.scrollBy(1);
141
- if (matchesKey(input, Key.pageUp)) scroll.scrollBy(-scroll.viewportHeight);
142
- if (matchesKey(input, Key.pageDown)) scroll.scrollBy(scroll.viewportHeight);
143
- tui.requestRender();
144
- },
145
- };
146
- }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
123
+ /** Details occupy the same above-editor widget, never an overlay. */
124
+ export class WorkDetail {
125
+ private readonly text: Text;
126
+ private readonly scroll: ScrollView;
127
+ private readonly title: string;
128
+ private theme: Theme;
129
+ private readonly rows: () => number;
130
+ private readonly request: () => void;
131
+ constructor(title: string, body: string, theme: Theme, rows: () => number, request: () => void) {
132
+ this.title = title; this.theme = theme; this.rows = rows; this.request = request;
133
+ this.text = new Text(safeOutput(body) || "No output yet.", 0, 0);
134
+ this.scroll = new ScrollView(this.text, { overscroll: "contain", scrollbar: "hidden" });
135
+ }
136
+ render(width: number): string[] {
137
+ const height = Math.max(1, Math.min(12, Math.floor(this.rows() / 2) - 2));
138
+ this.scroll.updateLayout(this.text.render(width).length, height, this.request);
139
+ return [this.theme.fg("accent", truncateToWidth(this.title, width)), ...this.scroll.render(width).slice(this.scroll.scrollTop, this.scroll.scrollTop + height), this.theme.fg("dim", truncateToWidth("Alt+J/K scroll · Alt+I back · Alt+W collapse", width))];
140
+ }
141
+ setTheme(theme: Theme): void { this.theme = theme; this.invalidate(); }
142
+ invalidate(): void { this.text.invalidate(); this.scroll.invalidate(); }
143
+ handleInput(input: string): void {
144
+ if (matchesKey(input, Key.up)) this.scroll.scrollBy(-1);
145
+ if (matchesKey(input, Key.down)) this.scroll.scrollBy(1);
146
+ this.request();
147
+ }
147
148
  }
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { clean, type Board, type Paint } from "./board.ts";
2
+ import { age, clean, type Board, type Paint, type Row } from "./board.ts";
3
3
  import type { TodoSnapshot } from "./work-todo.ts";
4
4
 
5
5
  export interface WorkState {
@@ -8,33 +8,45 @@ export interface WorkState {
8
8
  error: string | undefined;
9
9
  }
10
10
 
11
- /** One bounded row. Attention survives before progress and descriptive labels. */
11
+ /** A small, persistent readout: names and current work, not just counts. */
12
12
  export function workStrip(state: WorkState, width: number, paint: Paint): string[] {
13
13
  const { board, todo, error } = state;
14
14
  if (!board.rows.length && !todo?.tasks.length && !error) return [];
15
- const attention = board.rows.filter(row => row.group === "attention" || row.group === "to_land");
16
- const urgent = attention.length ? `!${attention.length}` : error ? "!" : "";
17
- const prefix = `WORK${urgent ? ` ${urgent}` : ""}`;
18
- const counts: string[] = [];
19
- if (todo?.tasks.length) counts.push(`${todo.tasks.filter(task => task.status === "completed").length}/${todo.tasks.length} todos`);
20
- const jobs = board.rows.filter(row => row.kind === "job").length;
21
- const agents = board.rows.filter(row => row.kind !== "job").length;
22
- if (agents) counts.push(`${agents} agent${agents === 1 ? "" : "s"}`);
23
- if (jobs) counts.push(`${jobs} job${jobs === 1 ? "" : "s"}`);
24
- const item = attention[0];
25
- const detail = error ? `unavailable · ${clean(error)}` : item ? `${clean(item.name)} · ${clean(item.state)}`
26
- : todo?.tasks.find(task => task.status === "in_progress")?.content;
27
- let line = prefix;
28
- for (const count of counts) {
29
- const next = `${line} · ${count}`;
30
- if (visibleWidth(next) + 8 <= width) line = next;
15
+ const lines = [paint.fg("dim", "WORK · Alt+W expand")];
16
+ if (error) lines.push(paint.fg("warning", `! unavailable · ${clean(error)}`));
17
+ const tasks = todo?.tasks ?? [];
18
+ const active = tasks.find(task => task.status === "in_progress");
19
+ const next = tasks.find(task => task.status === "pending");
20
+ const current = active ?? next;
21
+ if (current) lines.push(`${paint.fg("accent", active ? "→ Task" : "· Next")} ${clean(current.content)}`);
22
+ else if (tasks.length) lines.push(paint.fg("muted", `Tasks · ${tasks.filter(task => task.status === "completed").length}/${tasks.length} completed`));
23
+ const priority = (row: Row) => row.group === "attention" || row.group === "to_land" ? 0 : row.group === "running" ? 1 : 2;
24
+ for (const [label, members] of [
25
+ ["Agents", board.rows.filter(row => row.kind !== "job")],
26
+ ["Jobs", board.rows.filter(row => row.kind === "job")],
27
+ ] as const) {
28
+ const rows = [...members].sort((a, b) => priority(a) - priority(b));
29
+ if (!rows.length) continue;
30
+ let line = `${label} `;
31
+ let shown = 0;
32
+ for (const row of rows) {
33
+ const warning = priority(row) === 0;
34
+ const entry = `${warning ? "! " : ""}${clean(row.name)} · ${clean(row.state)} · ${age(row.age_ms)}`;
35
+ const remaining = rows.length - shown - 1;
36
+ const suffix = remaining ? ` +${remaining} more` : "";
37
+ const join = shown ? " | " : "";
38
+ if (shown && visibleWidth(line + join + entry + suffix) > width) break;
39
+ const room = Math.max(0, width - visibleWidth(line + join + suffix));
40
+ line += join + paint.fg(warning ? "warning" : "muted", truncateToWidth(entry, room));
41
+ shown++;
42
+ }
43
+ if (shown < rows.length) line += paint.fg("dim", ` +${rows.length - shown} more`);
44
+ lines.push(line);
31
45
  }
32
- if (detail && width - visibleWidth(line) > 12) line += ` · ${truncateToWidth(clean(detail), width - visibleWidth(line) - 11)}`;
33
- if (visibleWidth(line) + 8 <= width) line += " /work";
34
- return [truncateToWidth(paint.fg(urgent ? "warning" : "accent", line), width)];
46
+ return lines.map(line => truncateToWidth(line, width));
35
47
  }
36
48
 
37
- /** A plain readout also works in modes where custom terminal UI is unavailable. */
49
+ /** A plain readout also works in modes where terminal UI is unavailable. */
38
50
  export function workText(state: WorkState): string {
39
51
  const lines = ["WORK · todos: Pi branch · agents/jobs: Herdr owner pane"];
40
52
  if (state.error) lines.push(state.error);
@@ -18,7 +18,7 @@ wrangle read NAME_OR_PANE [--host H]
18
18
  ```
19
19
 
20
20
  The board groups what you started by what you must do: TO LAND (a finished child with commits), ATTENTION (blocked, stalled, failed), RUNNING, and WAITING (queued for a host). Each agent row shows its harness and model; each job row its command.
21
- Your prompt context carries host status and a `To land:` line. In Pi, the one-row `WORK` strip sits above the editor. `/work` opens read-only todos and agent/job inspection. Todos follow the Pi branch; agents and jobs follow the owner pane. `/wrangle` retains host, profile, and inbox administration.
21
+ Your prompt context carries host status and a `To land:` line. In Pi, the compact `WORK` widget shows the current task and named agents and jobs above the editor. Alt+W or `/work` expands it in place. Work inspection stays inline, not in a popup. Todos follow the Pi branch; agents and jobs follow the owner pane. `/wrangle` retains host, profile, and inbox administration.
22
22
 
23
23
  ## Delegate
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/oisinpi",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Oisin's pi packages in one install: prewalk, todo, wrangle, and guardrails.",
5
5
  "keywords": [
6
6
  "guardrails",
@@ -18,10 +18,10 @@
18
18
  },
19
19
  "type": "module",
20
20
  "dependencies": {
21
- "@oisin-ee/pi-guardrails": "0.5.0",
22
- "@oisin-ee/pi-prewalk": "0.5.0",
23
- "@oisin-ee/pi-todo": "0.5.0",
24
- "@oisin-ee/pi-wrangle": "0.12.1"
21
+ "@oisin-ee/pi-guardrails": "0.5.1",
22
+ "@oisin-ee/pi-prewalk": "0.5.1",
23
+ "@oisin-ee/pi-todo": "0.5.1",
24
+ "@oisin-ee/pi-wrangle": "0.12.2"
25
25
  },
26
26
  "bundleDependencies": [
27
27
  "@oisin-ee/pi-guardrails",