pi-pier 0.2.3 → 0.2.5

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/index.ts CHANGED
@@ -7,10 +7,10 @@
7
7
  * Capabilities:
8
8
  * - `todo_write`: replace the complete todo list; the current pi session JSONL is authoritative,
9
9
  * and branches automatically roll back in DSH-aligned semantics.
10
- * - `subagent`: foreground/background delegation. Each child pane is an interactive pi TUI
11
- * with an independent session/context that humans can enter directly; the controller uses
12
- * herdr for state-gated injection (idle only), waiting (agent.wait), and JSONL result reads.
13
- * - `list_agents` / `send_message` (followUp queue semantics) / `interrupt_agent` (esc).
10
+ * - `subagent`: foreground/background delegation plus resume/list/send/interrupt actions.
11
+ * Each child pane is an interactive pi TUI with an independent session/context that humans
12
+ * can enter directly; the controller uses herdr for state-gated injection (idle only),
13
+ * waiting (agent.wait), and JSONL result reads.
14
14
  * - `/todos`, TUI widget, and herdr title projection with graceful degradation without herdr.
15
15
  *
16
16
  * pi 0.84.2 contract (validated):
@@ -111,9 +111,14 @@ export default async function (pi: ExtensionAPI) {
111
111
 
112
112
  /** Tier 2: worker role badge parsed from the env manifest and kept visible while idle. */
113
113
  let roleBadge: string | null = null;
114
+ /** Human-gate depth; ask_user_question and external herdr:blocked events share it. */
115
+ let blockedDepth = 0;
114
116
 
115
117
  function reportAgent(state: 'working' | 'idle' | 'blocked', activity: string | null): void {
116
118
  if (!client.available) return;
119
+ // While a human gate is open, never let tool-badge / turn_start / settle overwrite blocked.
120
+ // Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
121
+ if (blockedDepth > 0 && state !== 'blocked') return;
117
122
  const message = activity ?? (state === 'idle' ? roleBadge : null);
118
123
  client.reportAgent(state, message).catch(() => {});
119
124
  }
@@ -156,8 +161,8 @@ export default async function (pi: ExtensionAPI) {
156
161
  progressSuffix: suffix,
157
162
  lastWriteAt: todos.lastWriteAt,
158
163
  });
159
- // D97: narrow-frame static state shares inputs with the pane title; thinking tokens cannot reach it, so a static frame would freeze.
160
- updateSlimFrame(title);
164
+ // D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
165
+ updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
161
166
  client.reportMetadata({
162
167
  session: label,
163
168
  items: todos.items,
@@ -369,15 +374,16 @@ export default async function (pi: ExtensionAPI) {
369
374
  if (plan.notice && !isSubagent) {
370
375
  const running = subagentPort.current?.listRunningSubs() ?? [];
371
376
  const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
372
- const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(list_agents 查看状态);若不等待,请说明放弃原因。`;
377
+ const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
373
378
  void sendUserMessageIn(notice);
374
379
  }
375
380
  });
376
381
 
377
- // v1.3 M8: report blocked state ourselves (D29's exemption relies on blocked visibility).
378
- // Observed: pi 0.84.2 core emits no "herdr:blocked" (the official integration listener is dead code),
379
- // so this extension manages enterBlocked/exitBlocked while ask_user_question waits for human input.
380
- let blockedDepth = 0;
382
+ // Official herdr:pi is full-lifecycle authority when installed: it reports blocked
383
+ // only on the herdr:blocked event (pi core never emits it). Custom source=pi-herdr
384
+ // pane.report_agent is ignored while that authority is live
385
+ // (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
386
+ // herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
381
387
  function enterBlocked(label: string | null): void {
382
388
  blockedDepth += 1;
383
389
  reportAgent('blocked', label);
@@ -392,14 +398,34 @@ export default async function (pi: ExtensionAPI) {
392
398
  void client.reportAskFlag(null).catch(() => {});
393
399
  }
394
400
  }
395
- (pi as { events?: { on?: (ev: string, cb: (data: unknown) => void) => void } }).events?.on?.(
396
- 'herdr:blocked',
397
- (data) => {
398
- const d = data as { active?: boolean; label?: string } | undefined;
399
- if (d?.active) enterBlocked(typeof d.label === 'string' ? d.label : null);
401
+
402
+ // emit() is synchronous; this flag stops our own listener from double-counting depth.
403
+ let publishingHerdrBlocked = false;
404
+ function publishHerdrBlocked(active: boolean, label: string | null): void {
405
+ publishingHerdrBlocked = true;
406
+ try {
407
+ if (active) enterBlocked(label);
400
408
  else exitBlocked();
401
- },
402
- );
409
+ pi.events.emit(
410
+ 'herdr:blocked',
411
+ active ? { active: true, ...(label ? { label } : {}) } : { active: false },
412
+ );
413
+ } finally {
414
+ publishingHerdrBlocked = false;
415
+ }
416
+ }
417
+ pi.events.on('herdr:blocked', (data) => {
418
+ if (publishingHerdrBlocked) return;
419
+ if (!data || typeof data !== 'object') {
420
+ exitBlocked();
421
+ return;
422
+ }
423
+ if (!('active' in data) || data.active !== true) {
424
+ exitBlocked();
425
+ return;
426
+ }
427
+ enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
428
+ });
403
429
 
404
430
  /* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
405
431
 
@@ -423,9 +449,10 @@ export default async function (pi: ExtensionAPI) {
423
449
  const ui = (ctx as {
424
450
  ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
425
451
  }).ui;
426
- enterBlocked(question);
427
- // v1.3 M8 observed: herdr keeps detecting working while waiting for a human, overriding a one-shot blocked report;
428
- // repeat it every 5s so the pane stays blocked and remains visible/GC-exempt.
452
+ publishHerdrBlocked(true, question);
453
+ // Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
454
+ // to later working reports; refresh while the gate is open. Do not re-emit
455
+ // herdr:blocked here — official blockedCount is edge-triggered.
429
456
  const hb = setInterval(() => { reportAgent('blocked', question); }, 5000);
430
457
  try {
431
458
  const answer = await ui?.input?.(question, 'your answer');
@@ -435,7 +462,7 @@ export default async function (pi: ExtensionAPI) {
435
462
  };
436
463
  } finally {
437
464
  clearInterval(hb);
438
- exitBlocked();
465
+ publishHerdrBlocked(false, null);
439
466
  }
440
467
  },
441
468
  });
@@ -12,12 +12,12 @@ export const NOTICE_MAX_SHOWN = 3;
12
12
  * Collapse a batch into one message body.
13
13
  * - empty → null (do not inject)
14
14
  * - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
15
- * - >max → first max originals + a tail pointing at history / list_agents
15
+ * - >max → first max originals + a tail pointing at history / subagent list
16
16
  */
17
17
  export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
18
18
  if (contents.length === 0) return null;
19
19
  if (contents.length <= max) return contents.join('\n\n');
20
20
  const hidden = contents.length - max;
21
- const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 resume_subagent 工具描述);在跑代理用 list_agents 查看。`;
21
+ const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
22
22
  return [...contents.slice(0, max), tail].join('\n\n');
23
23
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "role": "master",
3
- "version": "1.3.1",
4
- "description": "主控角色(WS-D7 自应用;D83 镜像源):自研核心全量(todos/协调/subagent 族/终端族)+ pi 内建检视执行 + web_search;D82 unknownTools=allow——用户安装的任意扩展默认可见可用(install 即授予)",
3
+ "version": "1.4.0",
4
+ "description": "主控角色(WS-D7 自应用;D83 镜像源):核心工具(todos/协调/subagent/terminal)+ pi 内建 + web_search;D82 unknownTools=allow。v1.4.0: 工具合并 terminal 6→1, subagent 5→1",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
8
8
  "todo_write", "ask_user_question",
9
- "subagent", "resume_subagent", "list_agents", "send_message", "interrupt_agent",
10
- "terminal_open", "terminal_send", "terminal_read", "terminal_signal", "terminal_close", "terminal_list",
9
+ "subagent",
10
+ "terminal",
11
11
  "read", "grep", "glob", "bash", "pwsh", "edit", "write",
12
12
  "web_search"
13
13
  ],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "role": "worker-default",
3
- "version": "1.3.1",
4
- "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial",
3
+ "version": "1.4.0",
4
+ "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.4.0: 工具合并后更新 deny 列表",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
@@ -11,16 +11,7 @@
11
11
  ],
12
12
  "rules": {
13
13
  "subagent": "deny",
14
- "resume_subagent": "deny",
15
- "list_agents": "deny",
16
- "send_message": "deny",
17
- "interrupt_agent": "deny",
18
- "terminal_open": "deny",
19
- "terminal_send": "deny",
20
- "terminal_read": "deny",
21
- "terminal_signal": "deny",
22
- "terminal_close": "deny",
23
- "terminal_list": "deny",
14
+ "terminal": "deny",
24
15
  "*": "allow"
25
16
  }
26
17
  },
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 {
@@ -122,7 +122,11 @@ export function createSessionIo(h: SessionIoHost): SessionIo {
122
122
  ): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
123
123
  for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
124
124
  const entries = readSessionFile(file);
125
- if (entries.length === 0) continue;
125
+ // readSessionFile returns null when the path is missing or unreadable.
126
+ // herdr often reports a .jsonl path before the worker creates the file;
127
+ // waitAgent(idle) then returns immediately and this path used to throw
128
+ // `Cannot read properties of null (reading 'length')` (session 01a055c5).
129
+ if (!entries?.length) continue;
126
130
  const r = lastAssistantText(entries, { sinceTs });
127
131
  if (r?.text) return { text: r.text, pendingTool: false, activity: true };
128
132
  if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
@@ -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
+ }