@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +239 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +330 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,131 @@
1
+ import { goalStoragePath, ensureGoalStorageDirectory } from "./goal-root.ts";
2
+ /**
3
+ * Short per-goal filesystem lock (follow-up Stage 4).
4
+ *
5
+ * An exclusive lock is acquired through atomic creation of a lock file under
6
+ * `.pi/goals/.locks/<goalId>.lock` carrying { pid, startedAt } metadata.
7
+ * Acquisition is bounded: on contention the caller waits briefly and retries,
8
+ * and stale locks (older than the TTL, or whose pid is no longer alive) are
9
+ * recovered by removal. The lock serializes cross-process mutations of the
10
+ * same goal; the optimistic revision check (goal-service.ts) detects stale
11
+ * writers even when the lock hand-off is fast.
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import type { GoalFileContext } from "./goal-files.ts";
17
+
18
+ export const GOAL_LOCK_DIR = ".pi/goals/.locks";
19
+
20
+ export interface GoalLock {
21
+ release(): void;
22
+ }
23
+
24
+ // P1-5: strictly bounded acquisition so the main thread never freezes for
25
+ // seconds under cross-process contention. The default window is 8×25ms ≈ 200ms;
26
+ // the persist path passes an even tighter bound (4×25ms ≈ 100ms). A contended
27
+ // write now fails fast with a typed error instead of blocking the TUI for the
28
+ // old 100×25ms ≈ 2.5s worst case; the optimistic revision check (goal-service)
29
+ // is the real guard against concurrent writers.
30
+ //
31
+ // NAF (2026-08-06): the default window drops to 8×1ms ≈ 8ms of sleep (+ the
32
+ // per-attempt staleness check ≈ 8ms) — 10x+ smaller main-thread block under
33
+ // long contention. Brief contention (<10ms) still succeeds; long-held locks
34
+ // fail fast and the caller defers the write (P1-3 turn buffer / persist-skip),
35
+ // so the TUI never freezes on a busy lock.
36
+ const DEFAULT_ACQUIRE_ATTEMPTS = 8;
37
+ const DEFAULT_RETRY_MS = 1;
38
+ const DEFAULT_STALE_TTL_MS = 30_000;
39
+
40
+ function safeLockName(goalId: string): string {
41
+ return goalId.replace(/[^A-Za-z0-9._-]/g, "_");
42
+ }
43
+
44
+ function pidAlive(pid: number): boolean {
45
+ if (!Number.isInteger(pid) || pid <= 0) return false;
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ } catch (err) {
50
+ return (err as NodeJS.ErrnoException).code === "EPERM";
51
+ }
52
+ }
53
+
54
+ function readLockPayload(lockPath: string): { pid?: number; startedAt?: string } {
55
+ try {
56
+ const raw = JSON.parse(fs.readFileSync(lockPath, "utf8")) as Record<string, unknown>;
57
+ return {
58
+ pid: typeof raw.pid === "number" ? raw.pid : undefined,
59
+ startedAt: typeof raw.startedAt === "string" ? raw.startedAt : undefined,
60
+ };
61
+ } catch {
62
+ // Unparseable payload means a writer crashed mid-create: treat as stale.
63
+ return {};
64
+ }
65
+ }
66
+
67
+ function sleepMs(ms: number): void {
68
+ // Synchronous bounded wait (Atomics.wait is legal on the main thread and
69
+ // does not burn CPU like a spin loop).
70
+ const buffer = new Int32Array(new SharedArrayBuffer(4));
71
+ Atomics.wait(buffer, 0, 0, ms);
72
+ }
73
+
74
+ /**
75
+ * Acquire the exclusive per-goal lock. Throws after the bounded attempt window
76
+ * if a live writer never releases it. The caller MUST release() the returned
77
+ * lock (use a finally block).
78
+ */
79
+ export function acquireGoalLock(
80
+ ctx: GoalFileContext,
81
+ goalId: string,
82
+ opts: { attempts?: number; retryMs?: number; staleTtlMs?: number } = {},
83
+ ): GoalLock {
84
+ const attempts = opts.attempts ?? DEFAULT_ACQUIRE_ATTEMPTS;
85
+ const retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
86
+ const ttlMs = opts.staleTtlMs ?? DEFAULT_STALE_TTL_MS;
87
+ const lockDir = goalStoragePath(ctx, GOAL_LOCK_DIR);
88
+ const lockPath = path.join(lockDir, `${safeLockName(goalId)}.lock`);
89
+ ensureGoalStorageDirectory(ctx, GOAL_LOCK_DIR, false);
90
+ const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
91
+
92
+ for (let attempt = 0; attempt < attempts; attempt++) {
93
+ try {
94
+ fs.writeFileSync(lockPath, payload, { flag: "wx" });
95
+ let released = false;
96
+ return {
97
+ release(): void {
98
+ if (released) return;
99
+ released = true;
100
+ try {
101
+ fs.unlinkSync(lockPath);
102
+ } catch {
103
+ // Lock already gone (stale recovery elsewhere); nothing to do.
104
+ }
105
+ },
106
+ };
107
+ } catch (err) {
108
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
109
+ // Bounded stale-lock recovery.
110
+ try {
111
+ const stat = fs.statSync(lockPath);
112
+ const payloadInfo = readLockPayload(lockPath);
113
+ const pid = payloadInfo.pid;
114
+ const stale = Date.now() - stat.mtimeMs > ttlMs || (pid !== undefined && !pidAlive(pid));
115
+ if (stale) {
116
+ try {
117
+ fs.unlinkSync(lockPath);
118
+ } catch {
119
+ // Someone else recovered it first; retry acquisition.
120
+ }
121
+ continue;
122
+ }
123
+ } catch {
124
+ // Lock vanished between stat and read (holder released): retry.
125
+ continue;
126
+ }
127
+ sleepMs(retryMs);
128
+ }
129
+ }
130
+ throw new Error(`Timed out acquiring the goal lock for ${goalId} (${attempts} attempts). Another writer may hold it.`);
131
+ }
@@ -0,0 +1,95 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { loadSettingsSnapshot } from "../goal-settings.ts";
5
+
6
+ export interface GoalStorageContext {
7
+ cwd: string;
8
+ /** Plain snapshot for deferred readers; never capture an ExtensionContext. */
9
+ goalStorageRoot?: string;
10
+ sessionManager?: { getSessionId(): string | undefined };
11
+ }
12
+
13
+ // Host contexts change between hooks; the session manager owns their lifetime.
14
+ const roots = new WeakMap<object, { id: string | undefined; cwd: string; root: string }>();
15
+
16
+ export function goalStorageRoot(ctx: GoalStorageContext): string {
17
+ if (ctx.goalStorageRoot !== undefined) return ctx.goalStorageRoot;
18
+ // Low-level cwd-only callers retain the original zero-settings-I/O contract.
19
+ // Host sessions resolve configuration once; detached readers take a snapshot.
20
+ return ctx.sessionManager ? configuredGoalRoot(ctx) : path.resolve(ctx.cwd, ".pi/goals");
21
+ }
22
+
23
+ function configuredGoalRoot(ctx: GoalStorageContext): string {
24
+ if (ctx.goalStorageRoot !== undefined) return ctx.goalStorageRoot;
25
+ const manager = ctx.sessionManager;
26
+ const id = manager?.getSessionId();
27
+ const key = manager ?? ctx;
28
+ const pinned = roots.get(key);
29
+ if (pinned && pinned.id === id && pinned.cwd === ctx.cwd) return pinned.root;
30
+ const settings = loadSettingsSnapshot(ctx.cwd);
31
+ const source = settings.provenance.get("goalsRoot")?.source;
32
+ const invalid = settings.diagnostics.find(d => d.settingPath === "goalsRoot" && source !== "environment" && (d.scope === "project" || source !== "project"));
33
+ if (invalid) throw new Error(`Invalid goalsRoot: ${invalid.message}`);
34
+ const configured = settings.value.goalsRoot;
35
+ let root = path.resolve(ctx.cwd, ".pi/goals");
36
+ if (configured !== undefined) {
37
+ const expanded = configured === "~" ? os.homedir() : configured.startsWith("~/") ? path.join(os.homedir(), configured.slice(2)) : configured;
38
+ if (!path.isAbsolute(expanded) || expanded.includes("\0")) throw new Error("goalsRoot / PI_GOAL_ROOT must be an absolute path or ~/path; reload after correcting it.");
39
+ root = path.normalize(expanded);
40
+ fs.mkdirSync(root, { recursive: true });
41
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Goal root is a symlink: ${root}`);
42
+ root = fs.realpathSync(root);
43
+ // Metadata lives inside an override without perturbing the pool's mtime.
44
+ const metadata = path.join(root, ".metadata");
45
+ fs.mkdirSync(metadata, { recursive: true });
46
+ if (fs.lstatSync(metadata).isSymbolicLink()) throw new Error(`Goal metadata directory is a symlink: ${metadata}`);
47
+ }
48
+ roots.set(key, { id, cwd: ctx.cwd, root });
49
+ return root;
50
+ }
51
+
52
+ export function goalStorageContext(ctx: GoalStorageContext): GoalStorageContext {
53
+ return { cwd: ctx.cwd, goalStorageRoot: configuredGoalRoot(ctx) };
54
+ }
55
+
56
+ /** Logical paths stay compatible in saved goal files, independent of pool location. */
57
+ export function goalStoragePath(ctx: GoalStorageContext, logical: string): string {
58
+ const relative = logical.replace(/\\/g, "/");
59
+ if (relative !== ".pi/goals" && !relative.startsWith(".pi/goals/")) throw new Error(`Invalid goal storage path: ${logical}`);
60
+ const root = goalStorageRoot(ctx);
61
+ const result = path.resolve(root, relative.slice(".pi/goals".length + 1));
62
+ const inside = path.relative(root, result);
63
+ if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) throw new Error(`Goal path escapes root: ${logical}`);
64
+ return result;
65
+ }
66
+
67
+ export function isExternalGoalStorage(ctx: GoalStorageContext): boolean {
68
+ return goalStorageRoot(ctx) !== path.resolve(ctx.cwd, ".pi/goals");
69
+ }
70
+
71
+ export function goalPoolSnapshotPath(ctx: GoalStorageContext): string {
72
+ const root = goalStorageRoot(ctx);
73
+ return root === path.resolve(ctx.cwd, ".pi/goals")
74
+ ? path.join(path.dirname(root), ".goals-pool-snapshot.json")
75
+ : path.join(root, ".metadata", ".goals-pool-snapshot.json");
76
+ }
77
+
78
+ /** Validate managed ancestors on writes, without adding I/O to steady-state reads. */
79
+ export function ensureGoalStorageDirectory(ctx: GoalStorageContext, logical: string, verifyDefault = true): void {
80
+ const root = goalStorageRoot(ctx);
81
+ const target = goalStoragePath(ctx, logical);
82
+ if (root === path.resolve(ctx.cwd, ".pi/goals")) {
83
+ fs.mkdirSync(target, { recursive: true });
84
+ if (verifyDefault && fs.lstatSync(target).isSymbolicLink()) throw new Error(`Goal directory is a symlink: ${target}`);
85
+ return;
86
+ }
87
+ // An external pool removed during a session must not silently reappear.
88
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Goal root is a symlink: ${root}`);
89
+ let dir = root;
90
+ for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
91
+ dir = path.join(dir, part);
92
+ try { fs.mkdirSync(dir); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
93
+ if (fs.lstatSync(dir).isSymbolicLink()) throw new Error(`Goal directory is a symlink: ${dir}`);
94
+ }
95
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Structured audit dashboard model (plan §15).
3
+ *
4
+ * Pure derivation from the low-level auditor progress stream: five check
5
+ * stages (objective, verification, tasks, workspace, decision) with explicit
6
+ * pending/running/passed/failed states, plus the approval/rejection result
7
+ * card model. No TUI imports; the renderer and /goal-status share this model.
8
+ */
9
+
10
+ import type { AuditorProgress } from "../goal-auditor.ts";
11
+ import { formatDashboardDuration } from "./goal-dashboard-model.ts";
12
+
13
+ export type AuditCheckState = "pending" | "running" | "passed" | "failed";
14
+
15
+ export type AuditCheckId = "objective" | "verification" | "tasks" | "workspace" | "decision";
16
+
17
+ export interface AuditCheck {
18
+ id: AuditCheckId;
19
+ label: string;
20
+ state: AuditCheckState;
21
+ detail?: string;
22
+ }
23
+
24
+ export type AuditVerdict = "approved" | "disapproved" | "error";
25
+
26
+ export interface AuditorDashboardModel {
27
+ auditorLabel: string;
28
+ elapsedMs: number;
29
+ percentage?: number;
30
+ checks: AuditCheck[];
31
+ currentTool?: string;
32
+ currentToolArgs?: string;
33
+ recentOutput: string[];
34
+ /** Result verdict when the audit finished (drives the result card). */
35
+ verdict?: AuditVerdict | null;
36
+ /** Whether the audit session is still running (false when phase === done). */
37
+ active: boolean;
38
+ }
39
+
40
+ export interface AuditResultCard {
41
+ verdict: AuditVerdict;
42
+ label: string;
43
+ lines: string[];
44
+ }
45
+
46
+ const CHECK_LABELS: Array<{ id: AuditCheckId; label: string }> = [
47
+ { id: "objective", label: "Objective and success criteria" },
48
+ { id: "verification", label: "Verification contracts" },
49
+ { id: "tasks", label: "Tasks and recorded evidence" },
50
+ { id: "workspace", label: "Workspace inspection" },
51
+ { id: "decision", label: "Final decision" },
52
+ ];
53
+
54
+ export interface DeriveAuditorDashboardOptions {
55
+ auditorLabel?: string;
56
+ /** Result verdict when known (set after the audit session finishes). */
57
+ verdict?: AuditVerdict | null;
58
+ }
59
+
60
+ /**
61
+ * Derive the structured audit dashboard from the raw progress stream (§15.2).
62
+ *
63
+ * Active audit: checks advance through percentage bands (objective ≥20%,
64
+ * verification ≥40%, tasks ≥60%, workspace ≥80%, decision ≥80%) so the five
65
+ * stages light up in order. Finished audit: all checks pass except decision,
66
+ * which follows the verdict (approved → passed; disapproved/error → failed).
67
+ */
68
+ export function deriveAuditorDashboardModel(
69
+ progress: AuditorProgress,
70
+ options: DeriveAuditorDashboardOptions = {},
71
+ ): AuditorDashboardModel {
72
+ const pct = clampPercentage(progress.percentage);
73
+ const done = progress.phase === "done";
74
+ const verdict = options.verdict !== undefined ? options.verdict : null;
75
+
76
+ const checks: AuditCheck[] = CHECK_LABELS.map(({ id, label }) => {
77
+ if (!done) {
78
+ const state = checkStateActive(id, pct);
79
+ return { id, label, state };
80
+ }
81
+ if (id === "decision") {
82
+ const state: AuditCheckState = verdict === "approved" ? "passed" : verdict === "disapproved" || verdict === "error" ? "failed" : "passed";
83
+ return { id, label, state };
84
+ }
85
+ return { id, label, state: "passed" };
86
+ });
87
+
88
+ return {
89
+ auditorLabel: options.auditorLabel ?? progress.label ?? "Independent completion audit",
90
+ elapsedMs: progress.elapsedMs,
91
+ ...(progress.percentage !== undefined ? { percentage: pct } : {}),
92
+ checks,
93
+ currentTool: progress.currentTool,
94
+ currentToolArgs: progress.currentToolArgs,
95
+ recentOutput: progress.recentOutput,
96
+ ...(verdict ? { verdict } : {}),
97
+ active: !done,
98
+ };
99
+ }
100
+
101
+ function checkStateActive(id: AuditCheckId, pct: number): AuditCheckState {
102
+ switch (id) {
103
+ case "objective":
104
+ return pct >= 20 ? "passed" : "running";
105
+ case "verification":
106
+ return pct >= 40 ? "passed" : pct >= 20 ? "running" : "pending";
107
+ case "tasks":
108
+ return pct >= 60 ? "passed" : pct >= 40 ? "running" : "pending";
109
+ case "workspace":
110
+ return pct >= 80 ? "passed" : pct >= 60 ? "running" : "pending";
111
+ case "decision":
112
+ return pct >= 80 ? "running" : "pending";
113
+ }
114
+ }
115
+
116
+ function clampPercentage(value: number | undefined): number {
117
+ if (value === undefined) return 0;
118
+ return Math.min(100, Math.max(0, Math.round(value)));
119
+ }
120
+
121
+ /** Human duration label for the audit header (reused formatter). */
122
+ export function formatAuditElapsed(elapsedMs: number): string {
123
+ return formatDashboardDuration(Math.floor(elapsedMs / 1000));
124
+ }
125
+
126
+ /**
127
+ * Build the result card (§15.4). Approval shows the fixed acceptance lines;
128
+ * rejection extracts concise findings from the auditor report (bullet-style or
129
+ * the leading non-marker lines, capped at three); errors state the failure.
130
+ */
131
+ export function deriveAuditResultCard(verdict: AuditVerdict, report: string): AuditResultCard {
132
+ if (verdict === "approved") {
133
+ return {
134
+ verdict,
135
+ label: "APPROVED",
136
+ lines: ["Objective satisfied.", "Verification requirements satisfied.", "Required tasks and evidence accepted."],
137
+ };
138
+ }
139
+ if (verdict === "error") {
140
+ return {
141
+ verdict,
142
+ label: "ERROR",
143
+ lines: [report.trim() ? oneLine(report) : "The auditor could not finish the review."],
144
+ };
145
+ }
146
+ const findings = extractFindings(report);
147
+ return {
148
+ verdict,
149
+ label: "CHANGES REQUIRED",
150
+ lines: findings.length > 0 ? findings : [oneLine(report) || "The auditor requested changes — review the report."],
151
+ };
152
+ }
153
+
154
+ function extractFindings(report: string): string[] {
155
+ const lines = report.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
156
+ const findings: string[] = [];
157
+ for (const line of lines) {
158
+ if (/^<(approved|disapproved)\s*\/>/.test(line)) continue;
159
+ const cleaned = line.replace(/^[-*•✗✓]\s*/, "").replace(/^(\d+[.)])\s*/, "").trim();
160
+ if (!cleaned) continue;
161
+ // Skip section headers ("Audit report:", "Findings:") and generic
162
+ // "Auditor: ..." attribution lines — they are not findings.
163
+ if (/^auditor\s*:/i.test(cleaned)) continue;
164
+ if (/^[a-z][a-z ]{0,24}:$/i.test(cleaned)) continue;
165
+ if (cleaned.length > 200) findings.push(`${cleaned.slice(0, 197)}...`);
166
+ else findings.push(cleaned);
167
+ if (findings.length >= 3) break;
168
+ }
169
+ return findings;
170
+ }
171
+
172
+ function oneLine(value: string): string {
173
+ return value.replace(/\s+/g, " ").trim();
174
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared bordered-dialog scaffold (P1-11): the line/horiz/inner-width helpers
3
+ * were copy-pasted across the escape dialog, the task-list confirmation
4
+ * dialog, and the task-list overlay. Collapsed here with byte-identical
5
+ * output; the renderers above these helpers keep their own headers, options,
6
+ * and footers.
7
+ */
8
+
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+
11
+ /** Inner content width between the ││ borders for a dialog at termWidth. */
12
+ export function dialogInnerWidth(termWidth: number, maxInner = 64): number {
13
+ return Math.min(termWidth, maxInner) - 2;
14
+ }
15
+
16
+ /** One bordered line: exactly innerWidth visible chars between ││. */
17
+ export function borderedLine(accent: (s: string) => string, innerWidth: number, content: string): string {
18
+ const vis = visibleWidth(content);
19
+ const fill = innerWidth - vis;
20
+ return accent("│") + content + (fill > 0 ? " ".repeat(fill) : "") + accent("│");
21
+ }
22
+
23
+ /** The ── run used by ┌/├/└ borders. */
24
+ export function horizontalRule(innerWidth: number): string {
25
+ return "─".repeat(innerWidth);
26
+ }