pi-pier 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/slim-frame.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * D97 slim frame: when a pane is too narrow or short for the minimum usable TUI,
3
- * cover it with a non-capturing full-screen overlay containing a static frame sourced from the pane title.
3
+ * cover it with a non-capturing overlay. Content is three-tier: full TUI (overlay
4
+ * hidden), an activity-anchored todo window when ≥3 rows fit, else the pane title.
4
5
  *
5
6
  * Why (observed by the user): after the heat view compresses an unfocused pane, streaming worker thinking
6
7
  * continuously repaints the entire screen in the narrow pane, causing flicker. The static frame updates only on
@@ -22,11 +23,21 @@
22
23
  * flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
23
24
  */
24
25
 
26
+ import { isArchived } from './stale-core.ts';
27
+ import type { TodoItem } from './vocab.ts';
28
+ import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
29
+
25
30
  /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
26
- * Below either threshold the pane cannot display a readable TUI, so it renders the title as a static frame. */
31
+ * Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
27
32
 
28
33
  export const SLIM_MIN_COLS = 24;
29
34
  export const SLIM_MIN_ROWS = 12;
35
+ /** Overlay todo window needs this many rows after wrap; below that, fall back to the title frame. */
36
+ export const SLIM_TODO_MIN_ROWS = 3;
37
+ /** Narrower than this, todo lines wrap into a column; keep the title frame instead. */
38
+ export const SLIM_TODO_MIN_COLS = 16;
39
+ /** Empty-list copy: the pane is alive but has no plan yet (replaces a lone ·). */
40
+ export const SLIM_EMPTY_COPY = 'no todos yet';
30
41
 
31
42
  /** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
32
43
  export function isSlimFrame(cols: number, rows: number): boolean {
@@ -115,6 +126,93 @@ export function frameLines(
115
126
  return out;
116
127
  }
117
128
 
129
+ function padFrame(
130
+ content: readonly string[],
131
+ opts: { width: number; rows: number; colorize: (s: string) => string; vAlign: 'center' | 'top' },
132
+ ): string[] {
133
+ const width = Math.max(1, Math.floor(opts.width));
134
+ const rows = Math.max(1, Math.floor(opts.rows));
135
+ const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
136
+ const body = content.slice(0, rows);
137
+ const top = opts.vAlign === 'center' ? Math.max(0, Math.floor((rows - body.length) / 2)) : 0;
138
+ const out: string[] = [];
139
+ for (let i = 0; i < rows; i++) {
140
+ const idx = i - top;
141
+ out.push(idx >= 0 && idx < body.length ? pad(opts.colorize(body[idx])) : ' '.repeat(width));
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function wrapTodoBody(items: readonly TodoItem[], width: number): string[] {
147
+ return renderTodoGroups(items).flatMap((line) => wrapByWidth(line, width));
148
+ }
149
+
150
+ function itemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
151
+ if (a.length !== b.length) return false;
152
+ for (let i = 0; i < a.length; i++) {
153
+ const x = a[i];
154
+ const y = b[i];
155
+ if (x.content !== y.content || x.status !== y.status || x.blocker !== y.blocker || x.phase !== y.phase) {
156
+ return false;
157
+ }
158
+ }
159
+ return true;
160
+ }
161
+
162
+ export interface SlimFrameInput {
163
+ title?: string | null;
164
+ items?: readonly TodoItem[];
165
+ lastWriteAt?: number | null;
166
+ now?: number;
167
+ }
168
+
169
+ /**
170
+ * Choose overlay content: todo window when the pane can show ≥3 wrapped rows at ≥16 cols;
171
+ * otherwise the pane title (or SLIM_EMPTY_COPY when the list is empty).
172
+ */
173
+ export function slimContentLines(
174
+ input: SlimFrameInput & { width: number; rows: number; colorize?: (s: string) => string },
175
+ ): string[] {
176
+ const width = Math.max(1, Math.floor(input.width));
177
+ const rows = Math.max(1, Math.floor(input.rows));
178
+ const colorize = input.colorize ?? ((s: string) => s);
179
+ const items = input.items ?? [];
180
+ const title = (input.title ?? '').trim();
181
+ const fallback = title || SLIM_EMPTY_COPY;
182
+ const titleFrame = () => frameLines(fallback, { width, rows, colorize });
183
+
184
+ if (rows < SLIM_TODO_MIN_ROWS || width < SLIM_TODO_MIN_COLS) return titleFrame();
185
+ if (items.length === 0) return titleFrame();
186
+ if (isArchived(items, input.lastWriteAt ?? null, input.now ?? Date.now())) return titleFrame();
187
+
188
+ const header = wrapByWidth(formatTodoSummary(items), width);
189
+ const fullBody = wrapTodoBody(items, width);
190
+ if (header.length + fullBody.length <= rows) {
191
+ const packed = [...header, ...fullBody];
192
+ if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
193
+ return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
194
+ }
195
+
196
+ const footerProbe = wrapByWidth(' +99 hidden (99✓) · /todos', width);
197
+ const itemBudget = rows - header.length - footerProbe.length;
198
+ if (itemBudget < 1) return titleFrame();
199
+
200
+ const [start, end] = anchorTodoRange(
201
+ items,
202
+ (s, e) => wrapTodoBody(items.slice(s, e), width).length <= itemBudget,
203
+ );
204
+ const kept = items.slice(start, end);
205
+ const body = wrapTodoBody(kept, width);
206
+ if (body.length === 0 || body.length > itemBudget) return titleFrame();
207
+
208
+ const hidden = items.filter((_, i) => i < start || i >= end);
209
+ const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
210
+ const footer = wrapByWidth(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
211
+ const packed = [...header, ...body, ...footer];
212
+ if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
213
+ return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
214
+ }
215
+
118
216
  /* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
119
217
 
120
218
  interface FrameTui {
@@ -122,7 +220,9 @@ interface FrameTui {
122
220
  }
123
221
 
124
222
  class SlimFrameComponent {
125
- private text = '';
223
+ private title = '';
224
+ private items: readonly TodoItem[] = [];
225
+ private lastWriteAt: number | null = null;
126
226
  private lastRows = 0;
127
227
  private cache: { width: number; lines: string[] } | null = null;
128
228
  private readonly tui: FrameTui;
@@ -137,21 +237,30 @@ class SlimFrameComponent {
137
237
  onViewport(cols: number, rows: number): boolean {
138
238
  if (this.lastRows !== rows) {
139
239
  this.lastRows = rows;
140
- this.cache = null; // A row-count change requires re-centering.
240
+ this.cache = null; // A row-count change requires re-layout.
141
241
  }
142
242
  return isSlimFrame(cols, rows);
143
243
  }
144
244
 
145
- setText(text: string): void {
146
- if (this.text === text) return;
147
- this.text = text;
245
+ setSnapshot(title: string, items: readonly TodoItem[], lastWriteAt: number | null): void {
246
+ if (this.title === title && this.lastWriteAt === lastWriteAt && itemsEqual(this.items, items)) return;
247
+ this.title = title;
248
+ this.items = items;
249
+ this.lastWriteAt = lastWriteAt;
148
250
  this.cache = null;
149
251
  this.tui.requestRender();
150
252
  }
151
253
 
152
254
  render(width: number): string[] {
153
255
  if (this.cache && this.cache.width === width) return this.cache.lines;
154
- const lines = frameLines(this.text, { width, rows: this.lastRows, colorize: this.colorize });
256
+ const lines = slimContentLines({
257
+ title: this.title,
258
+ items: this.items,
259
+ lastWriteAt: this.lastWriteAt,
260
+ width,
261
+ rows: this.lastRows,
262
+ colorize: this.colorize,
263
+ });
155
264
  this.cache = { width, lines };
156
265
  return lines;
157
266
  }
@@ -214,7 +323,7 @@ export function registerSlimFrame(eventCtx: unknown): void {
214
323
  }
215
324
  }
216
325
 
217
- /** Update static-frame content (same arguments/source as the pane-title projection; null means no plan). */
218
- export function updateSlimFrame(title: string | null): void {
219
- active?.setText(title ?? '');
326
+ /** Update overlay snapshot. Title remains the fallback; items drive the todo window. */
327
+ export function updateSlimFrame(input: SlimFrameInput): void {
328
+ active?.setSnapshot(input.title ?? '', input.items ?? [], input.lastWriteAt ?? null);
220
329
  }
@@ -122,13 +122,13 @@ export function buildAliveNotice(
122
122
  `Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
123
123
  'The foreground wait has moved it to background so this call returns now.',
124
124
  'Do NOT redo its work. When it settles you will receive a notification with its closing output.',
125
- 'Use list_agents to check its live state; send_message to give it follow-up work.',
125
+ 'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
126
126
  ].join(' ');
127
127
  }
128
128
  return [
129
129
  `The subagent "${description}" is ALIVE in pane ${paneId} (agent_status=${status}${activity}) — this "no readable output" result only means its final answer could not be read yet, NOT that the task failed.`,
130
130
  'Do NOT redo its work. It is being moved to background; you will be notified when it settles.',
131
- 'Use list_agents to check its live state.',
131
+ 'Use subagent(action: "list") to check its live state.',
132
132
  ].join(' ');
133
133
  }
134
134
 
@@ -141,7 +141,7 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
141
141
  'It keeps running in background; once the human answers it resumes, and you will be notified when it settles.',
142
142
  'Do NOT take over its work and do NOT answer on the human\'s behalf.',
143
143
  'Tell the user to open that pane in herdr and answer directly.',
144
- 'Only if the user explicitly authorizes you: relay their decision to the subagent via send_message — do not redo the work yourself.',
144
+ 'Only if the user explicitly authorizes you: relay their decision to the subagent via subagent(action: "send") — do not redo the work yourself.',
145
145
  ].join(' ');
146
146
  }
147
147
 
@@ -245,7 +245,7 @@ export function createPoller(h: PollerHost): Poller {
245
245
  h.writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
246
246
  const notes = h.reconcileOnSettlement(description, 'failed');
247
247
  const notice = h.withReconcileNotes(
248
- `Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run list_agents to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
248
+ `Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run subagent(action: "list") to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
249
249
  notes,
250
250
  );
251
251
  try {
@@ -96,7 +96,7 @@ export function registerTerminal(
96
96
  if (openCount >= MAX_TERMINALS) {
97
97
  return {
98
98
  ok: false,
99
- error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal_close before opening another`,
99
+ error: `terminal limit reached (max ${MAX_TERMINALS} open terminals); close one with terminal(action: "close") before opening another`,
100
100
  };
101
101
  }
102
102
  const entry: TerminalEntry = {
@@ -143,7 +143,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
143
143
  if (text.includes('\x1b')) {
144
144
  return {
145
145
  ok: false,
146
- error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal_signal for control keys, or plain commands only',
146
+ error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal(action: "signal") for control keys, or plain commands only',
147
147
  };
148
148
  }
149
149
  // Why: Preserve the established compatibility and safety behavior (C0).
@@ -152,7 +152,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
152
152
  if (bad) {
153
153
  return {
154
154
  ok: false,
155
- error: `unsupported input: control character 0x${bad[0].charCodeAt(0).toString(16)}; terminal_send accepts plain text commands only (use terminal_signal for ctrl+c etc.)`,
155
+ error: `unsupported input: control character 0x${bad[0].charCodeAt(0).toString(16)}; terminal send accepts plain text commands only (use terminal(action: "signal") for ctrl+c etc.)`,
156
156
  };
157
157
  }
158
158
  return { ok: true, text: stripped };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Activity-anchored todo window used by the pi widget and the slim overlay.
3
+ *
4
+ * Why: both surfaces must keep the in-progress row visible instead of a fixed
5
+ * head/tail slice. The widget budgets unwrapped render lines; the overlay
6
+ * supplies its own `fits` so wrapping can consume extra rows.
7
+ */
8
+ import { countTodos, type TodoItem, type TodoStatus } from './vocab.ts';
9
+
10
+ export const TODO_MARKS: Record<TodoStatus, string> = {
11
+ pending: '○',
12
+ in_progress: '▶',
13
+ completed: '✓',
14
+ blocked: '■',
15
+ abandoned: '✗',
16
+ };
17
+
18
+ export function formatTodoSummary(items: readonly TodoItem[]): string {
19
+ const c = countTodos(items as TodoItem[]);
20
+ return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
21
+ }
22
+
23
+ /** Share phase rendering so widget and overlay order entries identically. */
24
+ export function renderTodoGroups(items: readonly TodoItem[]): string[] {
25
+ const groups = new Map<string, TodoItem[]>();
26
+ for (const it of items) {
27
+ const key = it.phase ?? '';
28
+ const arr = groups.get(key) ?? [];
29
+ arr.push(it);
30
+ groups.set(key, arr);
31
+ }
32
+ const lines: string[] = [];
33
+ for (const [phase, list] of groups) {
34
+ if (phase) lines.push(` [${phase}]`);
35
+ for (const it of list) {
36
+ const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
37
+ lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
38
+ }
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ /**
44
+ * Expand around the active anchor while `fits(start, end)` remains true.
45
+ * Prefers upcoming work; the anchor stays visible. All-completed lists keep
46
+ * the original head-shrink (widget-compatible).
47
+ */
48
+ export function anchorTodoRange(
49
+ items: readonly TodoItem[],
50
+ fits: (start: number, end: number) => boolean,
51
+ ): [number, number] {
52
+ if (items.length === 0) return [0, 0];
53
+ if (fits(0, items.length)) return [0, items.length];
54
+ const anchor = items.findIndex((it) => it.status === 'in_progress');
55
+ const anchorIdx = anchor >= 0
56
+ ? anchor
57
+ : items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
58
+ if (anchorIdx < 0) {
59
+ let end = items.length;
60
+ while (end > 1 && !fits(0, end)) end -= 1;
61
+ return [0, end];
62
+ }
63
+ let start = anchorIdx;
64
+ let end = anchorIdx + 1;
65
+ let growTail = true;
66
+ while ((end < items.length || start > 0) && (end - start) < items.length) {
67
+ if (growTail && end < items.length) {
68
+ end += 1;
69
+ if (!fits(start, end)) { end -= 1; break; }
70
+ } else if (start > 0) {
71
+ start -= 1;
72
+ if (!fits(start, end)) { start += 1; break; }
73
+ } else if (end < items.length) {
74
+ end += 1;
75
+ if (!fits(start, end)) { end -= 1; break; }
76
+ } else {
77
+ break;
78
+ }
79
+ growTail = !growTail;
80
+ }
81
+ return [start, end];
82
+ }