gentle-pi 2.4.0 → 2.5.0

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 (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -0,0 +1,219 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import { realpathSync } from "node:fs";
5
+ import { isAbsolute, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ export const REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA = "gentle-pi.review-session-standing-permission/v1";
9
+ export const REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL = Symbol.for(REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA);
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ export interface ReviewSessionManager {
14
+ getSessionId(): unknown;
15
+ }
16
+
17
+ export interface ReviewSessionContext {
18
+ cwd: string;
19
+ mode?: string;
20
+ hasUI: boolean;
21
+ ui?: { getAllThemes?: () => readonly unknown[] };
22
+ sessionManager: ReviewSessionManager;
23
+ }
24
+
25
+ export interface ReviewSessionIdentity {
26
+ readonly sessionManager: ReviewSessionManager;
27
+ readonly sessionId: string;
28
+ readonly worktreeRoot: string;
29
+ // A SHA-256 digest of the canonical Git common directory. It is stable for
30
+ // sibling worktrees of one clone without serializing a filesystem path.
31
+ readonly repositoryIdentity: string;
32
+ }
33
+
34
+ interface ReviewSessionPermissionRegistry {
35
+ readonly schema: typeof REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA;
36
+ readonly permissions: WeakMap<object, Map<string, Set<string>>>;
37
+ readonly revocationEpochs: WeakMap<object, Map<string, number>>;
38
+ }
39
+
40
+ type GlobalRegistry = Record<symbol, unknown>;
41
+
42
+ function registry(): ReviewSessionPermissionRegistry | undefined {
43
+ const globalRegistry = globalThis as GlobalRegistry;
44
+ const current = globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL];
45
+ if (current === undefined) {
46
+ const created: ReviewSessionPermissionRegistry = {
47
+ schema: REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA,
48
+ permissions: new WeakMap(),
49
+ revocationEpochs: new WeakMap(),
50
+ };
51
+ globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL] = created;
52
+ return created;
53
+ }
54
+ if (
55
+ typeof current !== "object" ||
56
+ current === null ||
57
+ (current as { schema?: unknown }).schema !== REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA ||
58
+ !((current as { permissions?: unknown }).permissions instanceof WeakMap) ||
59
+ !((current as { revocationEpochs?: unknown }).revocationEpochs instanceof WeakMap)
60
+ ) return undefined;
61
+ return current as ReviewSessionPermissionRegistry;
62
+ }
63
+
64
+ function exactSessionId(sessionManager: ReviewSessionManager): string | undefined {
65
+ try {
66
+ const value = sessionManager.getSessionId();
67
+ return typeof value === "string" && value.length > 0 ? value : undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ function canonicalRepositoryIdentity(commonDir: string): string {
74
+ return `sha256:${createHash("sha256").update(commonDir).digest("hex")}`;
75
+ }
76
+
77
+ function validAbsoluteGitPath(value: string): boolean {
78
+ return isAbsolute(value) && value.length > 0 && !value.includes("\n") && !value.includes("\r");
79
+ }
80
+
81
+ export async function resolveCanonicalGitWorktreeRoot(cwd: string): Promise<string | undefined> {
82
+ try {
83
+ const result = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
84
+ encoding: "utf8",
85
+ timeout: 5_000,
86
+ maxBuffer: 64 * 1024,
87
+ });
88
+ const output = result.stdout.trim();
89
+ if (!validAbsoluteGitPath(output)) return undefined;
90
+ return await realpath(output);
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** Resolves a non-secret, clone-stable identity from Git's canonical common dir. */
97
+ export async function resolveCanonicalGitRepositoryIdentity(cwd: string): Promise<string | undefined> {
98
+ try {
99
+ const result = await execFileAsync("git", ["-C", cwd, "rev-parse", "--git-common-dir"], {
100
+ encoding: "utf8",
101
+ timeout: 5_000,
102
+ maxBuffer: 64 * 1024,
103
+ });
104
+ const output = result.stdout.trim();
105
+ if (output.length === 0 || output.includes("\n") || output.includes("\r")) return undefined;
106
+ return canonicalRepositoryIdentity(await realpath(resolve(cwd, output)));
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** The parent AgentRunner binds a child task to this same digest at spawn time. */
113
+ export function resolveCanonicalGitRepositoryIdentitySync(cwd: string): string | undefined {
114
+ try {
115
+ const output = execFileSync("git", ["-C", cwd, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024 }).trim();
116
+ if (output.length === 0 || output.includes("\n") || output.includes("\r")) return undefined;
117
+ return canonicalRepositoryIdentity(realpathSync(resolve(cwd, output)));
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ function hasInteractiveTui(context: ReviewSessionContext): boolean {
124
+ if (context.mode !== undefined) return context.mode === "tui";
125
+ try {
126
+ // Pi 0.85 exposes ctx.mode in the documented SDK surface, while its
127
+ // compatibility runner omits that property. In the latter, the real TUI
128
+ // has themes and RPC's deliberately unsupported TUI surface returns none.
129
+ return (context.ui?.getAllThemes?.().length ?? 0) > 0;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ export async function captureReviewSessionIdentity(
136
+ context: ReviewSessionContext,
137
+ processEnv: NodeJS.ProcessEnv = process.env,
138
+ resolveRoot: (cwd: string) => Promise<string | undefined> = resolveCanonicalGitWorktreeRoot,
139
+ resolveRepositoryIdentity: (cwd: string) => Promise<string | undefined> = resolveCanonicalGitRepositoryIdentity,
140
+ ): Promise<ReviewSessionIdentity | undefined> {
141
+ if (processEnv.GENTLE_PI_AGENTS_CHILD === "1" || context.hasUI !== true || !hasInteractiveTui(context)) return undefined;
142
+ const sessionManager = context.sessionManager;
143
+ if (typeof sessionManager !== "object" || sessionManager === null) return undefined;
144
+ const sessionId = exactSessionId(sessionManager);
145
+ if (sessionId === undefined) return undefined;
146
+ const [worktreeRoot, repositoryIdentity] = await Promise.all([resolveRoot(context.cwd), resolveRepositoryIdentity(context.cwd)]);
147
+ if (worktreeRoot === undefined || repositoryIdentity === undefined) return undefined;
148
+ return { sessionManager, sessionId, worktreeRoot, repositoryIdentity };
149
+ }
150
+
151
+ export function sameReviewSessionIdentity(left: ReviewSessionIdentity, right: ReviewSessionIdentity): boolean {
152
+ return left.sessionManager === right.sessionManager && left.sessionId === right.sessionId && left.repositoryIdentity === right.repositoryIdentity;
153
+ }
154
+
155
+ function epochFor(state: ReviewSessionPermissionRegistry, sessionManager: object, sessionId: string): number {
156
+ return state.revocationEpochs.get(sessionManager)?.get(sessionId) ?? 0;
157
+ }
158
+
159
+ function advanceEpoch(state: ReviewSessionPermissionRegistry, sessionManager: object, sessionId: string): void {
160
+ let sessions = state.revocationEpochs.get(sessionManager);
161
+ if (sessions === undefined) {
162
+ sessions = new Map();
163
+ state.revocationEpochs.set(sessionManager, sessions);
164
+ }
165
+ sessions.set(sessionId, epochFor(state, sessionManager, sessionId) + 1);
166
+ }
167
+
168
+ export function reviewSessionPermissionEpoch(identity: ReviewSessionIdentity): number | undefined {
169
+ const state = registry();
170
+ return state === undefined ? undefined : epochFor(state, identity.sessionManager, identity.sessionId);
171
+ }
172
+
173
+ export function grantReviewSessionPermission(identity: ReviewSessionIdentity, expectedEpoch?: number): boolean {
174
+ const state = registry();
175
+ if (state === undefined || (expectedEpoch !== undefined && epochFor(state, identity.sessionManager, identity.sessionId) !== expectedEpoch)) return false;
176
+ let sessions = state.permissions.get(identity.sessionManager);
177
+ if (sessions === undefined) {
178
+ sessions = new Map();
179
+ state.permissions.set(identity.sessionManager, sessions);
180
+ }
181
+ let roots = sessions.get(identity.sessionId);
182
+ if (roots === undefined) {
183
+ roots = new Set();
184
+ sessions.set(identity.sessionId, roots);
185
+ }
186
+ roots.add(identity.repositoryIdentity);
187
+ return true;
188
+ }
189
+
190
+ // A grant belongs to one live session and one Git clone. It follows sibling
191
+ // worktrees through their common directory, never an unrelated repository.
192
+ export function hasReviewSessionPermission(identity: ReviewSessionIdentity): boolean {
193
+ return registry()?.permissions.get(identity.sessionManager)?.get(identity.sessionId)?.has(identity.repositoryIdentity) === true;
194
+ }
195
+
196
+ export function revokeReviewSessionPermission(identity: ReviewSessionIdentity): boolean {
197
+ const state = registry();
198
+ if (state === undefined) return false;
199
+ advanceEpoch(state, identity.sessionManager, identity.sessionId);
200
+ const sessions = state.permissions.get(identity.sessionManager);
201
+ const roots = sessions?.get(identity.sessionId);
202
+ if (sessions === undefined || roots === undefined) return false;
203
+ const removed = roots.delete(identity.repositoryIdentity);
204
+ if (roots.size === 0) sessions.delete(identity.sessionId);
205
+ if (sessions.size === 0) state.permissions.delete(identity.sessionManager);
206
+ return removed;
207
+ }
208
+
209
+ export function revokeReviewSessionPermissionsForSession(sessionManager: object, sessionId: string): boolean {
210
+ if (sessionId.length === 0) return false;
211
+ const state = registry();
212
+ if (state === undefined) return false;
213
+ advanceEpoch(state, sessionManager, sessionId);
214
+ const sessions = state.permissions.get(sessionManager);
215
+ if (sessions === undefined) return false;
216
+ const removed = sessions.delete(sessionId);
217
+ if (sessions.size === 0) state.permissions.delete(sessionManager);
218
+ return removed;
219
+ }
@@ -0,0 +1,138 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
3
+ import { renderUsageBar, type ProviderUsage } from "./shell-usage.ts";
4
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
5
+
6
+ export { gaugeTone, renderGauge, type GaugeTone };
7
+
8
+ // Gentle Shell status bar: one line of segments that replaces pi's built-in
9
+ // three-line footer. Everything here is pure so the bar can be rendered and
10
+ // verified without a live TUI.
11
+
12
+ export interface ShellBarModel {
13
+ cwd: string;
14
+ branch: string | null;
15
+ dirty: number | undefined;
16
+ sessionName: string | undefined;
17
+ modelId: string;
18
+ effort: string | undefined;
19
+ contextPercent: number | null;
20
+ contextWindow: number;
21
+ costTotal: number;
22
+ subscription: boolean;
23
+ usage: ProviderUsage | undefined;
24
+ statuses: string[];
25
+ }
26
+
27
+ export interface ShellBarTheme {
28
+ fg(color: string, text: string): string;
29
+ bold(text: string): string;
30
+ }
31
+
32
+ // Theme roles the bar paints with. Keys are pi theme colors; the Gentle themes
33
+ // map them to the rose palette (accent = rose, syntaxFunction = powder blue).
34
+ const ROLE = {
35
+ BRAND: "accent",
36
+ SEPARATOR: "dim",
37
+ PATH: "muted",
38
+ BRANCH: "text",
39
+ DIRTY: "warning",
40
+ MODEL: "text",
41
+ EFFORT: "syntaxFunction",
42
+ LABEL: "muted",
43
+ VALUE: "text",
44
+ STATUS: "muted",
45
+ SESSION: "dim",
46
+ } as const;
47
+
48
+ export const SHELL_BAR_BRAND = "✿ gentle-pi";
49
+ export const SHELL_BAR_SEPARATOR = "⟡";
50
+ export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
51
+ const RIGHT_PADDING = 2;
52
+ const COMPACT_BRANCH_WIDTH = 15;
53
+
54
+ export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
55
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
56
+ const value = env.GENTLE_PI_SHELL?.trim().toLowerCase();
57
+ return !(value === "0" || value === "false" || value === "off");
58
+ }
59
+
60
+ export function formatTokens(count: number): string {
61
+ if (count < 1000) return count.toString();
62
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
63
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
64
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
65
+ return `${Math.round(count / 1_000_000)}M`;
66
+ }
67
+
68
+ export function formatCost(total: number, subscription: boolean): string {
69
+ const amount = total >= 1 ? total.toFixed(2) : total.toFixed(3);
70
+ return subscription ? `$${amount} sub` : `$${amount}`;
71
+ }
72
+
73
+ // Extensions may paint their status themselves (pi-mcp-adapter does); the bar
74
+ // owns the palette, so their escapes go and the text takes the status role.
75
+ function sanitizeStatus(text: string): string {
76
+ return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim();
77
+ }
78
+
79
+ function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] {
80
+ const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : "";
81
+ const location = model.branch
82
+ ? `${theme.fg(ROLE.PATH, model.cwd)} ${theme.fg(ROLE.BRANCH, model.branch)}${dirty}`
83
+ : theme.fg(ROLE.PATH, model.cwd) + dirty;
84
+ const modelSegment = model.effort
85
+ ? `${theme.fg(ROLE.MODEL, model.modelId)} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.EFFORT, model.effort)}`
86
+ : theme.fg(ROLE.MODEL, model.modelId);
87
+ const percentText = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
88
+ const context = `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(model.contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
89
+ const cost = theme.fg(ROLE.VALUE, formatCost(model.costTotal, model.subscription));
90
+ const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
91
+ const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
92
+ return [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND), location, modelSegment, context, cost, ...(usage ? [usage] : []), ...statuses];
93
+ }
94
+
95
+ // When the line overflows, the location gives way first: the path shrinks to
96
+ // its last segment and a long branch is clipped, so the trailing statuses
97
+ // (MCP servers, extension notices) survive on ordinary terminal widths.
98
+ function compactModel(model: ShellBarModel): ShellBarModel {
99
+ const cwd = model.cwd.split("/").filter((part) => part.length > 0).pop() ?? model.cwd;
100
+ const branch = model.branch && visibleWidth(model.branch) > COMPACT_BRANCH_WIDTH ? clipText(model.branch, COMPACT_BRANCH_WIDTH) : model.branch;
101
+ return { ...model, cwd, branch };
102
+ }
103
+
104
+ // Plain clip: pi's truncateToWidth wraps the result in resets, which would end
105
+ // up inside a painted segment.
106
+ function clipText(text: string, max: number): string {
107
+ let clipped = "";
108
+ for (const char of text) {
109
+ if (visibleWidth(clipped + char) > max - 1) break;
110
+ clipped += char;
111
+ }
112
+ return `${clipped}…`;
113
+ }
114
+
115
+ function joinSegments(segments: string[], theme: ShellBarTheme): string {
116
+ return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `);
117
+ }
118
+
119
+ export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
120
+ let segments = buildSegments(model, theme);
121
+ const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
122
+
123
+ let left = joinSegments(segments, theme);
124
+ if (right && visibleWidth(left) + RIGHT_PADDING + visibleWidth(right) <= width) {
125
+ const padding = " ".repeat(width - visibleWidth(left) - visibleWidth(right));
126
+ return [left + padding + right];
127
+ }
128
+
129
+ if (visibleWidth(left) > width) {
130
+ segments = buildSegments(compactModel(model), theme);
131
+ left = joinSegments(segments, theme);
132
+ }
133
+ while (segments.length > 1 && visibleWidth(left) > width) {
134
+ segments.pop();
135
+ left = joinSegments(segments, theme);
136
+ }
137
+ return [truncateToWidth(left, width, "…")];
138
+ }
@@ -0,0 +1,136 @@
1
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ // Gentle Shell cards: the shape every Gentle notice takes in the transcript
4
+ // and above the editor. The same rounded frame as the prompt and the
5
+ // overlays, with the title in the card's tone. Pure: strings in, lines out.
6
+
7
+ export const CARD_TONE = {
8
+ INFO: "info",
9
+ SUCCESS: "success",
10
+ WARNING: "warning",
11
+ ERROR: "error",
12
+ } as const;
13
+
14
+ export type CardTone = (typeof CARD_TONE)[keyof typeof CARD_TONE];
15
+
16
+ export interface Card {
17
+ title: string;
18
+ subtitle?: string;
19
+ body: string[];
20
+ tone: CardTone;
21
+ glyph?: string;
22
+ }
23
+
24
+ export interface CardTheme {
25
+ fg(color: string, text: string): string;
26
+ }
27
+
28
+ export interface CardRenderOptions {
29
+ expanded: boolean;
30
+ /** Right-aligned hint in the top rule, e.g. the expand key. May carry ANSI. */
31
+ hint?: string;
32
+ /** Paints a finished line, e.g. with the panel background. */
33
+ paint?: (line: string) => string;
34
+ }
35
+
36
+ export const CARD_GLYPH = "✿";
37
+ const TONE_ROLE: Record<CardTone, string> = {
38
+ [CARD_TONE.INFO]: "customMessageLabel",
39
+ [CARD_TONE.SUCCESS]: "success",
40
+ [CARD_TONE.WARNING]: "warning",
41
+ [CARD_TONE.ERROR]: "error",
42
+ };
43
+ const HINT_ROLE = "dim";
44
+ const SUBTITLE_ROLE = "muted";
45
+ const BODY_ROLE = "text";
46
+ const SEPARATOR = "·";
47
+ const FRAME_COLUMNS = 4;
48
+
49
+ function rule(length: number): string {
50
+ return "─".repeat(Math.max(0, length));
51
+ }
52
+
53
+ function titleText(card: Card, theme: CardTheme): { styled: string; width: number } {
54
+ const head = `${card.glyph ?? CARD_GLYPH} ${card.title}`;
55
+ const styled = card.subtitle
56
+ ? `${theme.fg(TONE_ROLE[card.tone], head)} ${theme.fg(SUBTITLE_ROLE, SEPARATOR)} ${theme.fg(SUBTITLE_ROLE, card.subtitle)}`
57
+ : theme.fg(TONE_ROLE[card.tone], head);
58
+ return { styled, width: visibleWidth(head) + (card.subtitle ? visibleWidth(card.subtitle) + 3 : 0) };
59
+ }
60
+
61
+ function bodyLines(card: Card, innerWidth: number): string[] {
62
+ return card.body.flatMap((paragraph) => (paragraph === "" ? [""] : wrapTextWithAnsi(paragraph, innerWidth)));
63
+ }
64
+
65
+ // The left rail, corners included, carries the tone at full strength; the
66
+ // rest of the frame stays in the theme's border color, so the state reads from the rail.
67
+ const FRAME_ROLE = "border";
68
+
69
+ function soft(theme: CardTheme, _tone: CardTone, text: string): string {
70
+ return theme.fg(FRAME_ROLE, text);
71
+ }
72
+
73
+ export function cardTop(card: Card, theme: CardTheme, width: number, hint?: string): string {
74
+ const targetWidth = Math.max(0, Math.floor(width));
75
+ if (targetWidth === 0) return "";
76
+ if (targetWidth < 5) {
77
+ const left = theme.fg(TONE_ROLE[card.tone], "╭");
78
+ if (targetWidth === 1) return left;
79
+ return left + soft(theme, card.tone, `${rule(targetWidth - 2)}╮`);
80
+ }
81
+
82
+ const title = titleText(card, theme);
83
+ const fullHintWidth = hint ? visibleWidth(hint) + 2 : 0;
84
+ const shownHint = hint && title.width + 5 + fullHintWidth <= targetWidth ? hint : undefined;
85
+ const hintWidth = shownHint ? fullHintWidth : 0;
86
+ const titleWidth = Math.max(0, targetWidth - 5 - hintWidth);
87
+ const styledTitle = title.width <= titleWidth ? title.styled : truncateToWidth(title.styled, titleWidth, "");
88
+ const styledTitleWidth = title.width <= titleWidth ? title.width : visibleWidth(styledTitle);
89
+ const fill = rule(targetWidth - styledTitleWidth - 5 - hintWidth);
90
+ const tail = shownHint ? ` ${theme.fg(HINT_ROLE, shownHint)} ` : "";
91
+ return theme.fg(TONE_ROLE[card.tone], "╭") + soft(theme, card.tone, "─ ") + styledTitle + soft(theme, card.tone, ` ${fill}`) + tail + soft(theme, card.tone, "╮");
92
+ }
93
+
94
+ export function cardLine(text: string, tone: CardTone, theme: CardTheme, width: number): string {
95
+ const targetWidth = Math.max(0, Math.floor(width));
96
+ if (targetWidth === 0) return "";
97
+ const left = theme.fg(TONE_ROLE[tone], "│");
98
+ if (targetWidth === 1) return left;
99
+ if (targetWidth === 2) return left + soft(theme, tone, "│");
100
+ if (targetWidth === 3) return `${left} ${soft(theme, tone, "│")}`;
101
+
102
+ const innerWidth = targetWidth - FRAME_COLUMNS;
103
+ const clipped = innerWidth === 0 ? "" : truncateToWidth(text, innerWidth, "…");
104
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
105
+ return `${left} ${clipped}${padding} ${soft(theme, tone, "│")}`;
106
+ }
107
+
108
+ export function cardBottom(tone: CardTone, theme: CardTheme, width: number): string {
109
+ const targetWidth = Math.max(0, Math.floor(width));
110
+ if (targetWidth === 0) return "";
111
+ const left = theme.fg(TONE_ROLE[tone], "╰");
112
+ if (targetWidth === 1) return left;
113
+ return left + soft(theme, tone, `${rule(targetWidth - 2)}╯`);
114
+ }
115
+
116
+ export function cardInnerWidth(width: number): number {
117
+ return Math.max(1, width - FRAME_COLUMNS);
118
+ }
119
+
120
+ export function renderCard(card: Card, theme: CardTheme, width: number, options: CardRenderOptions): string[] {
121
+ const innerWidth = Math.max(1, width - FRAME_COLUMNS);
122
+ const paint = options.paint ?? ((line: string) => line);
123
+ const top = cardTop(card, theme, width, options.hint);
124
+ const bottom = cardBottom(card.tone, theme, width);
125
+ const lines = bodyLines(card, innerWidth);
126
+ const body = (() => {
127
+ if (lines.length === 0) return [];
128
+ if (!options.expanded) {
129
+ const first = lines.find((line) => line !== "") ?? "";
130
+ const clipped = lines.length > 1 ? truncateToWidth(first, Math.max(1, innerWidth - 1), "") + "…" : first;
131
+ return [cardLine(theme.fg(BODY_ROLE, clipped), card.tone, theme, width)];
132
+ }
133
+ return lines.map((line) => cardLine(line === "" ? "" : theme.fg(BODY_ROLE, line), card.tone, theme, width));
134
+ })();
135
+ return [top, ...body, bottom].map(paint);
136
+ }