pi-pier 0.3.0 → 0.3.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.
Files changed (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -1,13 +1,6 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D65, D75). */
1
+ /** Single owner of todo state; every mutation path (tool, human edit, M17 reconcile, archive) lands here (D65, D75). */
2
2
  import { EventEmitter } from 'node:events';
3
- import {
4
- applyTodoEdits,
5
- foldLatestTodosMeta,
6
- listsEqual,
7
- type TodoEdit,
8
- type TodoItem,
9
- } from './todo-core.ts';
10
- import { countCompletedTransitions } from './progress-core.ts';
3
+ import { applyTodoEdits, completionTransitions, foldLatestTodosMeta, listsEqual, type TodoEdit, type TodoItem } from './todo-core.ts';
11
4
  import type { RuntimeRoleManifest } from './tool-gate.ts';
12
5
 
13
6
  export interface TodosConfig {
@@ -17,21 +10,13 @@ export interface TodosConfig {
17
10
 
18
11
  export type TodoCompletionSource = 'tool' | 'reconcile' | 'human' | 'archive';
19
12
 
20
- export interface TodoCompletedEvent {
21
- count: number;
22
- at: number;
23
- source: TodoCompletionSource;
24
- }
25
-
26
13
  export class TodosService extends EventEmitter {
27
14
  private _items: TodoItem[] = [];
28
- /**
29
- * Live list (read-only). Mutations must go through replace/applyEdits/rebuild.
30
- */
15
+ /** Live list (read-only). Mutations must go through replace/applyEdits/rebuild. */
31
16
  get items(): readonly TodoItem[] {
32
17
  return this._items;
33
18
  }
34
- /** Why: Preserve the established compatibility and safety behavior. */
19
+ /** Clock anchor for staleness/archiving; null until the first write. */
35
20
  lastWriteAt: number | null = null;
36
21
  readonly config: TodosConfig;
37
22
 
@@ -40,17 +25,14 @@ export class TodosService extends EventEmitter {
40
25
  this.config = config;
41
26
  }
42
27
 
43
- /** Why: Preserve the established compatibility and safety behavior (D75). */
28
+ /** D75: subagents are strict (one in_progress); the role manifest may force parallel mode. */
44
29
  static configFromRuntime(manifest: RuntimeRoleManifest | null, isSubagent: boolean): TodosConfig {
45
30
  const strict = isSubagent;
46
31
  const mode = manifest?.services?.todos?.mode;
47
- const allowParallelInProgress = mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict;
48
- return { strict, allowParallelInProgress };
49
- }
50
-
51
- /** Defensive copy for callers that must not mutate service state. */
52
- getSnapshot(): TodoItem[] {
53
- return this._items.map((it) => ({ ...it }));
32
+ return {
33
+ strict,
34
+ allowParallelInProgress: mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict,
35
+ };
54
36
  }
55
37
 
56
38
  replace(items: readonly TodoItem[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
@@ -76,24 +58,22 @@ export class TodosService extends EventEmitter {
76
58
  return { changed: true };
77
59
  }
78
60
 
79
- /** Why: Preserve the established compatibility and safety behavior (M16). */
61
+ /** M16: ETA and compaction boundaries feed off this single completion diff (same rule as the tool's D36 report). */
80
62
  private emitCompletedTransitions(
81
63
  before: readonly TodoItem[],
82
64
  after: readonly TodoItem[],
83
65
  source: TodoCompletionSource = 'tool',
84
66
  ): void {
85
- const count = countCompletedTransitions(before, after);
67
+ const count = completionTransitions(before, after).length;
86
68
  if (count > 0) this.emit('todo.completed', { count, at: Date.now(), source });
87
69
  }
88
70
 
71
+ /** Branch replay: fold the JSONL to the authoritative list. A branch with no snapshot (/new, or /tree
72
+ * back before the first todo_write) has no list, so the previous session's items must not survive. */
89
73
  rebuild(entries: readonly unknown[]): void {
90
- // Why: Preserve the established compatibility and safety behavior.
91
- // Why: Preserve the established compatibility and safety behavior.
92
74
  const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
93
- if (folded) {
94
- this._items = folded.items;
95
- this.lastWriteAt = folded.writtenAt;
96
- }
75
+ this._items = folded ? folded.items : [];
76
+ this.lastWriteAt = folded ? folded.writtenAt : null;
97
77
  this.emit('todo.updated', { items: this._items, reason: 'rebuild' });
98
78
  }
99
79
  }
package/src/tool-error.ts CHANGED
@@ -1,17 +1,11 @@
1
1
  /**
2
- * A1: pi only marks a tool result as failed when `execute()` throws — "Returning a value never sets
3
- * the error flag regardless of what properties you include in the return object"
4
- * (pi docs/extensions.md, "Signaling errors").
2
+ * pi only marks a tool result as failed when `execute()` throws; returning a value never sets the
3
+ * error flag (pi docs/extensions.md, "Signaling errors"). Every hard failure goes through
4
+ * `toolError()`, while deliberate non-failures (empty result set, "no match within Xms", a pane that
5
+ * has not produced output yet) stay normal results.
5
6
  *
6
- * The round-2 audit found 20+ real failures in pier's tools returned as plain text, so neither the
7
- * model nor pi's own hooks keyed on `event.isError` could see them (pier's B1 "subagent alive but
8
- * reported no output" self-heal was dead code for exactly that reason). Every hard failure now goes
9
- * through `toolError()`; deliberate non-failures (empty result set, "no match within Xms", a pane
10
- * that simply has not produced output yet) stay normal results.
11
- *
12
- * The "Error: " prefix the old text returns carried is dropped: the provider's tool-result error
13
- * channel and pi's red rendering already mark it, and keeping it would double the prefix once pi
14
- * formats a thrown error.
7
+ * The leading "Error: " text is stripped: the provider's tool-result error channel and pi's red
8
+ * rendering already mark it, and keeping it would double the prefix once pi formats a thrown error.
15
9
  */
16
10
  export class ToolError extends Error {
17
11
  constructor(message: string) {
@@ -20,7 +14,6 @@ export class ToolError extends Error {
20
14
  }
21
15
  }
22
16
 
23
- /** Throw a tool failure (never returns). */
24
17
  export function toolError(message: string): never {
25
18
  throw new ToolError(message);
26
19
  }
package/src/tool-gate.ts CHANGED
@@ -1,31 +1,40 @@
1
- /** Why: Preserve the established compatibility and safety behavior (C7, D76, D77, D79, WS-D6). */
1
+ /**
2
+ * Worker execution enforcement (C7, D76/D77/D82) — pure. RuntimeRoleManifest is THE runtime manifest
3
+ * shape; the file format lives in role-manifest.ts and the composed/persisted shapes derive from it
4
+ * (manifest-compose.ts, role-state.ts).
5
+ */
2
6
  import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
3
7
 
4
- /** Why: Preserve the established compatibility and safety behavior. */
5
8
  export interface RuntimeRoleManifest {
6
9
  role: string;
7
10
  version?: string;
8
11
  tools: string[];
9
12
  permissions: Record<string, PermissionAction>;
10
- /** Why: Preserve the established compatibility and safety behavior (D82). */
13
+ /** Stance for tools outside `tools`; 'allow' keeps user-installed extensions visible (D82). */
11
14
  unknownTools?: UnknownToolStance;
12
15
  services?: {
13
16
  todos?: {
14
17
  mode?: 'serial' | 'parallel';
15
18
  };
16
19
  };
20
+ /** P0 per-role guidelines (RFC §4.6): injected as a `pier-role` prompt section each turn. */
21
+ guidelines?: string[];
17
22
  }
18
23
 
19
- /** Why: Preserve the established compatibility and safety behavior. */
24
+ /** Parse the env-carried manifest; malformed input is null (fail-open: no gate) rather than a crash. */
20
25
  export function parseRuntimeManifest(envValue: string | undefined): RuntimeRoleManifest | null {
21
26
  if (!envValue) return null;
22
27
  try {
23
28
  const m = JSON.parse(envValue) as RuntimeRoleManifest;
24
29
  if (!m || typeof m.role !== 'string' || !Array.isArray(m.tools)) return null;
25
- // Why: Preserve the established compatibility and safety behavior (D82).
26
30
  if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
27
31
  m.unknownTools = 'deny';
28
32
  }
33
+ if (m.guidelines !== undefined) {
34
+ m.guidelines = Array.isArray(m.guidelines)
35
+ ? m.guidelines.filter((g) => typeof g === 'string' && g.trim() !== '')
36
+ : undefined;
37
+ }
29
38
  return m;
30
39
  } catch {
31
40
  return null;
@@ -43,7 +52,6 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
43
52
  const perm = manifest.permissions[toolName] ?? manifest.permissions['*'] ?? 'allow';
44
53
  const known = manifest.tools.includes(toolName);
45
54
  const stance = manifest.unknownTools ?? 'deny';
46
- // Why: Preserve the established compatibility and safety behavior (D82).
47
55
  if (perm === 'deny' || (!known && stance === 'deny')) {
48
56
  return {
49
57
  kind: 'deny',
@@ -59,27 +67,36 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
59
67
  return { kind: 'allow' };
60
68
  }
61
69
 
62
- /** Why: Preserve the established compatibility and safety behavior (D77, D82). */
63
- export function planActiveTools(
64
- manifestTools: string[],
65
- currentActive: string[],
70
+ /**
71
+ * 'allow' keeps every tool but the explicit deny rules (D82 user-installed-extension axis); 'deny'
72
+ * intersects with the manifest. Shared by planActiveTools (visibility) and role-state's switch
73
+ * planning (all-tools universe).
74
+ */
75
+ export function filterToolsByStance(
76
+ manifestTools: readonly string[],
77
+ universe: readonly string[],
66
78
  opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
67
- ): { next: string[]; changed: boolean } | null {
68
- if (manifestTools.length === 0 || currentActive.length === 0) return null;
79
+ ): string[] {
69
80
  if ((opts?.unknownTools ?? 'deny') === 'allow') {
70
81
  const denied = new Set(
71
82
  Object.entries(opts?.permissions ?? {})
72
- .filter(([, v]) => v === 'deny')
73
- .map(([k]) => k),
83
+ .filter(([, action]) => action === 'deny')
84
+ .map(([name]) => name),
74
85
  );
75
- const next = currentActive.filter((t) => !denied.has(t));
76
- if (next.length === 0) return null;
77
- return { next, changed: next.length !== currentActive.length };
86
+ return universe.filter((name) => !denied.has(name));
78
87
  }
79
88
  const wanted = new Set(manifestTools);
80
- const next = currentActive.filter((t) => wanted.has(t));
81
- if (next.length === 0) return null;
82
- const changed = next.length !== currentActive.length;
83
- return { next, changed };
89
+ return universe.filter((name) => wanted.has(name));
84
90
  }
85
91
 
92
+ /** Null means "keep the current active set": an empty result must never clear every tool (D77). */
93
+ export function planActiveTools(
94
+ manifestTools: readonly string[],
95
+ currentActive: readonly string[],
96
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
97
+ ): { next: string[]; changed: boolean } | null {
98
+ if (manifestTools.length === 0 || currentActive.length === 0) return null;
99
+ const next = filterToolsByStance(manifestTools, currentActive, opts);
100
+ if (next.length === 0) return null;
101
+ return { next, changed: next.length !== currentActive.length };
102
+ }
package/src/vocab.ts CHANGED
@@ -1,20 +1,17 @@
1
1
  /**
2
- * pi-herdr bridge vocabulary (M22: title is the board).
3
- *
4
- * DESIGN.md §4.2: no new transport. Wire = herdr NDJSON; todo authority = pi
5
- * session JSONL; display = pane.report_metadata title / state_labels.
2
+ * pi-herdr bridge vocabulary. DESIGN.md §4.2: no new transport — wire = herdr NDJSON, todo authority
3
+ * = pi session JSONL, display = pane.report_metadata title / state_labels.
6
4
  */
7
5
 
8
- /** Todo five-state (D34: three-state + blocked/abandoned, aligned with OMP). */
6
+ /** Five states (D34): three-state + blocked/abandoned, aligned with OMP. */
9
7
  export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'abandoned';
10
8
 
11
9
  export const TODO_STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'abandoned'];
12
10
 
13
- /** Todo item: content + status; optional blocker (blocked only) and phase (D43). No stable id (last-write-wins). */
11
+ /** Todo item: content + status; blocker only when blocked; optional phase groups (D43). No id (last-write-wins). */
14
12
  export interface TodoItem {
15
13
  content: string;
16
14
  status: TodoStatus;
17
- /** Non-empty string only when status==='blocked' (D34). */
18
15
  blocker?: string;
19
16
  /** Optional group name (D43); non-empty, ≤30 chars. */
20
17
  phase?: string;
@@ -27,58 +24,88 @@ export interface TodoSnapshot {
27
24
  items: TodoItem[];
28
25
  }
29
26
 
30
- /** Snapshot counts (confirmation copy and title projection). */
31
27
  export interface TodoCounts {
32
28
  pending: number;
33
29
  inProgress: number;
34
30
  completed: number;
35
- /** D91: blocked as its own column (title quartet ▶○■✓). */
31
+ /** D91: blocked is its own column (title quartet ▶○■✓). */
36
32
  blocked: number;
37
33
  }
38
34
 
39
35
  /** Source name for pane.report-agent. */
40
36
  export const REPORT_AGENT_SOURCE = 'pi-herdr';
41
37
 
42
- /** First-upgrade key used to clear the old 16-key chunked tokens (M13b leftover). */
38
+ /** First-upgrade key used to clear the old 16-key chunked tokens (M13b). */
43
39
  export const PI_HERDR_META_KEY = 'pi-herdr';
44
40
 
45
- /** Model-facing todo tool name (DSH-compatible). */
46
41
  export const TODO_TOOL_NAME = 'todo_write';
47
42
 
48
43
  /** Snapshot key in tool-result details (persisted in session JSONL). */
49
44
  export const TODO_DETAILS_KEY = 'pi-herdr.todo';
50
45
 
51
- /** Confirmation copy (byte-aligned with DSH so model vocab transfers; blocked is appended only when present). */
46
+ /** Confirmation copy (byte-aligned with DSH so model vocab transfers). */
52
47
  export function formatTodoConfirmation(items: readonly TodoItem[]): string {
53
48
  const c = countTodos(items);
54
49
  const base = `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
55
- // A4: blocked items used to be invisible here (the model read "nothing pending" while items sat
56
- // blocked waiting on a human). Appending only when non-zero keeps the DSH-aligned wording intact.
50
+ // Append blocked only when non-zero — the model must not read "nothing pending" while items sit
51
+ // blocked on a human. Zero keeps the DSH-aligned wording intact.
57
52
  return c.blocked > 0 ? `${base.slice(0, -1)}, ${c.blocked} blocked.` : base;
58
53
  }
59
54
 
55
+ /** Every status the todo model knows; anything else in a snapshot is corrupt data (D34). */
56
+ const STATUS_LOOKUP: Readonly<Record<TodoStatus, true>> = {
57
+ pending: true,
58
+ in_progress: true,
59
+ completed: true,
60
+ blocked: true,
61
+ abandoned: true,
62
+ };
63
+
64
+ /**
65
+ * Corrupt snapshots carry arbitrary JSON as `status` (`extractSnapshotFromDetails` does not validate),
66
+ * so every table lookup keyed by status must pass through this first — `TODO_MARKS['constructor']`
67
+ * would otherwise render a function, and `groups['constructor'].push` would throw.
68
+ */
69
+ export const isTodoStatus = (value: unknown): value is TodoStatus =>
70
+ typeof value === 'string' && Object.hasOwn(STATUS_LOOKUP, value);
71
+
72
+ const COUNTED_STATUSES = {
73
+ pending: 'pending',
74
+ in_progress: 'inProgress',
75
+ completed: 'completed',
76
+ blocked: 'blocked',
77
+ } as const satisfies Partial<Record<TodoStatus, keyof TodoCounts>>;
78
+
79
+ /** The four states that occupy a count column (`abandoned` is terminal and deliberately uncounted). */
80
+ const isCountedStatus = (status: TodoStatus): status is keyof typeof COUNTED_STATUSES =>
81
+ Object.hasOwn(COUNTED_STATUSES, status);
82
+
60
83
  export function countTodos(items: readonly TodoItem[]): TodoCounts {
61
- let pending = 0;
62
- let inProgress = 0;
63
- let completed = 0;
64
- let blocked = 0;
84
+ const counts: TodoCounts = { pending: 0, inProgress: 0, completed: 0, blocked: 0 };
65
85
  for (const it of items) {
66
- if (it.status === 'pending') pending++;
67
- else if (it.status === 'in_progress') inProgress++;
68
- else if (it.status === 'completed') completed++;
69
- else if (it.status === 'blocked') blocked++;
70
- // abandoned is omitted (D34); blocked is its own column (D91)
86
+ const status = it?.status;
87
+ if (isTodoStatus(status) && isCountedStatus(status)) counts[COUNTED_STATUSES[status]] += 1;
71
88
  }
72
- return { pending, inProgress, completed, blocked };
89
+ return counts;
73
90
  }
74
91
 
92
+ /** Why a settlement carried no closing text (p24-class mis-attribution vs a truly silent worker). */
93
+ export type SettlementNullReason = 'silent' | 'attribution-suspect' | 'extraction-failed';
94
+
95
+ const NULL_CLOSING_SENTENCE: Record<SettlementNullReason, string> = {
96
+ 'attribution-suspect': 'Its closing message could NOT be read — the session attribution is suspect (a wrong transcript may have been read). Check `subagent action output` / the ledger sessionFile before assuming it produced nothing; its work may be complete.',
97
+ 'extraction-failed': 'It appears to have ended with a final report, but the closing text could not be extracted — read it from the session transcript.',
98
+ silent: 'It left no closing message.',
99
+ };
100
+
75
101
  /** Subagent settlement notice (aligned with DSH). */
76
102
  export function formatSettlementNotice(
77
103
  agentId: string,
78
104
  closingMessage: string | null,
105
+ nullReason: SettlementNullReason = 'silent',
79
106
  ): string {
80
107
  const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
81
108
  return closingMessage
82
109
  ? `${head} Its closing message: ${closingMessage}`
83
- : `${head} It left no closing message.`;
110
+ : `${head} ${NULL_CLOSING_SENTENCE[nullReason]}`;
84
111
  }
package/src/ask-multi.ts DELETED
@@ -1,260 +0,0 @@
1
- /**
2
- * ask_user_question multi-select: pure state machine + a thin TUI component.
3
- *
4
- * Why: the previous multi path asked the human to *type* "1,3" into a text prompt,
5
- * which is the weakest part of the human gate. This module renders a real toggle
6
- * list (space to toggle, enter to confirm, esc to decline) with the authored
7
- * choices, an optional trailing free-text row, and a live selection count.
8
- *
9
- * The transition function is pure so the UX is testable without a terminal; the
10
- * component only maps keys to it. Installing the free-text row is opt-out
11
- * (`allowOther: false`) for pure-choice questions.
12
- */
13
- import { truncateStyled } from './ansi-text.ts';
14
-
15
- export interface MultiSelectOption {
16
- label: string;
17
- description?: string;
18
- }
19
-
20
- export interface MultiSelectConfig {
21
- options: readonly MultiSelectOption[];
22
- /** Render the trailing free-text row (pi-herdr's "Other"). */
23
- allowOther: boolean;
24
- /** 0-based index of the recommended option, marked in the list. */
25
- recommended?: number;
26
- }
27
-
28
- export interface MultiSelectState {
29
- /** Cursor row: 0..options.length-1 are choices, options.length is the Other row. */
30
- cursor: number;
31
- /** Toggle state per authored option (never includes the Other row). */
32
- selected: readonly boolean[];
33
- }
34
-
35
- export type MultiSelectKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'ignore';
36
-
37
- export type MultiSelectOutcome =
38
- | { kind: 'selected'; labels: string[] }
39
- | { kind: 'other' }
40
- | { kind: 'cancel' };
41
-
42
- export interface MultiSelectStep {
43
- state: MultiSelectState;
44
- outcome?: MultiSelectOutcome;
45
- }
46
-
47
- export function createMultiSelectState(config: MultiSelectConfig): MultiSelectState {
48
- return { cursor: 0, selected: config.options.map(() => false) };
49
- }
50
-
51
- /** Total rows including the optional Other row; the cursor wraps inside this. */
52
- export function rowCount(config: MultiSelectConfig): number {
53
- return config.options.length + (config.allowOther ? 1 : 0);
54
- }
55
-
56
- export function isOtherRow(state: MultiSelectState, config: MultiSelectConfig): boolean {
57
- return config.allowOther && state.cursor === config.options.length;
58
- }
59
-
60
- export function selectedLabels(state: MultiSelectState, config: MultiSelectConfig): string[] {
61
- const labels: string[] = [];
62
- config.options.forEach((option, i) => {
63
- if (state.selected[i]) labels.push(option.label);
64
- });
65
- return labels;
66
- }
67
-
68
- /** Apply one key. A returned outcome ends the interaction. */
69
- export function stepMultiSelect(state: MultiSelectState, key: MultiSelectKey, config: MultiSelectConfig): MultiSelectStep {
70
- const rows = rowCount(config);
71
- if (rows === 0) return key === 'cancel' ? { state, outcome: { kind: 'cancel' } } : { state };
72
- switch (key) {
73
- case 'cancel':
74
- return { state, outcome: { kind: 'cancel' } };
75
- case 'up':
76
- case 'down': {
77
- const delta = key === 'up' ? -1 : 1;
78
- const cursor = (state.cursor + delta + rows) % rows;
79
- return { state: { ...state, cursor } };
80
- }
81
- case 'all': {
82
- const anyUnselected = state.selected.some((v) => !v);
83
- return { state: { ...state, selected: state.selected.map(() => anyUnselected) } };
84
- }
85
- case 'toggle': {
86
- if (isOtherRow(state, config)) return { state };
87
- const selected = state.selected.map((v, i) => (i === state.cursor ? !v : v));
88
- return { state: { ...state, selected } };
89
- }
90
- case 'confirm': {
91
- if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
92
- // Enter on a choice row confirms the current toggles; a single-choice
93
- // question (multi: false) needs no separate space press.
94
- if (config.options.length === 1) {
95
- return { state, outcome: { kind: 'selected', labels: [config.options[0]!.label] } };
96
- }
97
- return { state, outcome: { kind: 'selected', labels: selectedLabels(state, config) } };
98
- }
99
- default:
100
- return { state };
101
- }
102
- }
103
-
104
- /* ── Key matching ──────────────────────────────────────────────────── */
105
-
106
- export type KeyMatcher = (data: string, key: Exclude<MultiSelectKey, 'ignore'>) => boolean;
107
-
108
- /**
109
- * Fallback matcher for raw key sequences, used when pi-tui cannot be imported.
110
- * Ordering inside `handleInput` matters for escape: arrow sequences are matched
111
- * before the bare ESC key because both start with 0x1b.
112
- */
113
- export const FALLBACK_KEY_SEQUENCES: Record<Exclude<MultiSelectKey, 'ignore'>, readonly string[]> = {
114
- up: ['\x1b[A', '\x1bOA', 'k'],
115
- down: ['\x1b[B', '\x1bOB', 'j'],
116
- toggle: [' '],
117
- confirm: ['\r', '\n'],
118
- cancel: ['\x1b', '\x03'],
119
- all: ['a', 'A'],
120
- };
121
-
122
- export const fallbackKeyMatcher: KeyMatcher = (data, key) => FALLBACK_KEY_SEQUENCES[key].includes(data);
123
-
124
- /**
125
- * Prefer pi-tui's `matchesKey` (it understands terminal variations and keybindings
126
- * remaps); fall back to the built-in sequences when the package is unavailable.
127
- */
128
- export async function loadKeyMatcher(): Promise<KeyMatcher> {
129
- try {
130
- const tui = await import('@earendil-works/pi-tui') as {
131
- matchesKey?: (data: string, key: unknown) => boolean;
132
- Key?: Record<string, unknown>;
133
- };
134
- const Key = tui.Key;
135
- if (typeof tui.matchesKey === 'function' && Key && typeof Key.up === 'string') {
136
- const map: Record<string, unknown> = {
137
- up: Key.up,
138
- down: Key.down,
139
- toggle: Key.space,
140
- confirm: Key.enter,
141
- cancel: Key.escape,
142
- all: 'a',
143
- };
144
- return (data, key) => tui.matchesKey!(data, map[key]);
145
- }
146
- } catch {
147
- /* pi-tui absent: use the built-in sequences. */
148
- }
149
- return fallbackKeyMatcher;
150
- }
151
-
152
- /* ── TUI component ─────────────────────────────────────────────────── */
153
-
154
- export interface MinimalTheme {
155
- fg(color: string, text: string): string;
156
- bold(text: string): string;
157
- }
158
-
159
- export interface MultiSelectComponent {
160
- render(width: number): string[];
161
- handleInput(data: string): void;
162
- invalidate(): void;
163
- }
164
-
165
- export interface MultiSelectUiOptions {
166
- title: string;
167
- config: MultiSelectConfig;
168
- theme: MinimalTheme;
169
- matcher: KeyMatcher;
170
- /** Called with the outcome; the host resolves ctx.ui.custom. */
171
- done(outcome: MultiSelectOutcome): void;
172
- /** Ask for the gate label used by the renderer (tests only). */
173
- requestRender?: () => void;
174
- }
175
-
176
- const RECOMMENDED = ' (Recommended)';
177
-
178
- export function multiSelectLines(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string[] {
179
- const lines: string[] = [];
180
- config.options.forEach((option, i) => {
181
- const cursor = i === state.cursor ? theme.fg('accent', '> ') : ' ';
182
- const box = state.selected[i] ? theme.fg('success', '[x]') : '[ ]';
183
- const label = `${option.label}${config.recommended === i ? RECOMMENDED : ''}`;
184
- const styled = state.selected[i] ? theme.bold(label) : label;
185
- const desc = option.description ? theme.fg('dim', ` — ${option.description}`) : '';
186
- lines.push(`${cursor}${box} ${styled}${desc}`);
187
- });
188
- if (config.allowOther) {
189
- const cursor = isOtherRow(state, config) ? theme.fg('accent', '> ') : ' ';
190
- lines.push(`${cursor}${theme.fg('dim', '[ ] Other (type your own)')}`);
191
- }
192
- return lines;
193
- }
194
-
195
- export function multiSelectFooter(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string {
196
- const picked = selectedLabels(state, config).length;
197
- const count = picked > 0 ? theme.fg('success', ` · ${picked} selected`) : '';
198
- return theme.fg('dim', '↑/↓ move · space toggle · a all · enter confirm · esc cancel') + count;
199
- }
200
-
201
- export function createMultiSelectComponent(opts: MultiSelectUiOptions): MultiSelectComponent {
202
- let state = createMultiSelectState(opts.config);
203
- let finished = false;
204
- const finish = (outcome: MultiSelectOutcome): void => {
205
- if (finished) return;
206
- finished = true;
207
- opts.done(outcome);
208
- };
209
- return {
210
- render(width: number): string[] {
211
- const head = opts.theme.bold(opts.title);
212
- const body = multiSelectLines(state, opts.config, opts.theme);
213
- const footer = multiSelectFooter(state, opts.config, opts.theme);
214
- return [head, ...body, '', footer].map((line) => truncateStyled(line, width));
215
- },
216
- handleInput(data: string): void {
217
- if (finished) return;
218
- // Arrow sequences are checked before the bare ESC key (shared 0x1b prefix).
219
- const key: MultiSelectKey = opts.matcher(data, 'up') ? 'up'
220
- : opts.matcher(data, 'down') ? 'down'
221
- : opts.matcher(data, 'toggle') ? 'toggle'
222
- : opts.matcher(data, 'confirm') ? 'confirm'
223
- : opts.matcher(data, 'cancel') ? 'cancel'
224
- : opts.matcher(data, 'all') ? 'all'
225
- : 'ignore';
226
- if (key === 'ignore') return;
227
- const next = stepMultiSelect(state, key, opts.config);
228
- state = next.state;
229
- opts.requestRender?.();
230
- if (next.outcome) finish(next.outcome);
231
- },
232
- invalidate(): void { /* No cached lines. */ },
233
- };
234
- }
235
-
236
- /**
237
- * Run the toggle list through `ctx.ui.custom`.
238
- * Returns null when the host cannot render custom components (RPC mode / older pi),
239
- * so the caller can fall back to the typed-index prompt.
240
- */
241
- export async function runMultiSelect(
242
- custom: (factory: unknown) => Promise<unknown>,
243
- title: string,
244
- config: MultiSelectConfig,
245
- ): Promise<MultiSelectOutcome | null> {
246
- const matcher = await loadKeyMatcher();
247
- const result = await custom((tui: unknown, theme: unknown, _keybindings: unknown, done: (value: unknown) => void) =>
248
- createMultiSelectComponent({
249
- title,
250
- config,
251
- theme: theme as MinimalTheme,
252
- matcher,
253
- done: (outcome) => done(outcome),
254
- requestRender: () => (tui as { requestRender?: () => void } | undefined)?.requestRender?.(),
255
- }));
256
- if (result === undefined || result === null) return null;
257
- const outcome = result as MultiSelectOutcome;
258
- if (outcome.kind === 'cancel' || outcome.kind === 'other' || outcome.kind === 'selected') return outcome;
259
- return null;
260
- }