gentle-pi 2.3.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,182 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
5
+ import { stripAnsi } from "../lib/terminal-theme.ts";
6
+
7
+ // The Gentle Todo extension: the `todo` tool, the card above the editor,
8
+ // the per-turn prompt block, and the staleness signal, driven by fakes.
9
+
10
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
11
+
12
+ interface Registered {
13
+ execute(toolCallId: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
14
+ renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
15
+ renderResult(result: unknown, options: { expanded: boolean }, theme: unknown): { render(width: number): string[] };
16
+ }
17
+
18
+ const plainTheme = {
19
+ fg(_color: string, text: string) {
20
+ return text;
21
+ },
22
+ strikethrough(text: string) {
23
+ return `~${text}~`;
24
+ },
25
+ };
26
+ const fakeTui = { requestRender() {} };
27
+
28
+ function fakePi() {
29
+ const handlers = new Map<string, Handler[]>();
30
+ const tools = new Map<string, Registered>();
31
+ const shortcuts = new Map<string, { handler(ctx: ExtensionContext): Promise<void> }>();
32
+ const pi = {
33
+ on(event: string, handler: Handler) {
34
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
35
+ },
36
+ registerTool(tool: Registered & { name: string }) {
37
+ tools.set(tool.name, tool);
38
+ },
39
+ registerShortcut(key: string, registration: { handler(ctx: ExtensionContext): Promise<void> }) {
40
+ shortcuts.set(key, registration);
41
+ },
42
+ } as unknown as ExtensionAPI;
43
+ const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
44
+ let last: unknown;
45
+ for (const handler of handlers.get(event) ?? []) last = await handler(payload, ctx);
46
+ return last;
47
+ };
48
+ return { pi, tools, shortcuts, fire };
49
+ }
50
+
51
+ function fakeContext(branch: unknown[] = [], hasUI = true) {
52
+ const widgets = new Map<string, (tui: unknown, theme: unknown) => { render(width: number): string[] }>();
53
+ const ctx = {
54
+ hasUI,
55
+ sessionManager: { getSessionId: () => "s1", getBranch: () => branch },
56
+ ui: {
57
+ setWidget(key: string, content: ((tui: unknown, theme: unknown) => { render(width: number): string[] }) | undefined) {
58
+ if (content === undefined) widgets.delete(key);
59
+ else widgets.set(key, content);
60
+ },
61
+ },
62
+ } as unknown as ExtensionContext;
63
+ const widget = () => {
64
+ const factory = widgets.get("gentle-todo");
65
+ return factory ? factory(fakeTui, plainTheme).render(70).map(stripAnsi) : undefined;
66
+ };
67
+ return { ctx, widgets, widget };
68
+ }
69
+
70
+ test("todoEnabled and todoCollapseKey read their environment flags", () => {
71
+ assert.equal(todoEnabled({}), true);
72
+ assert.equal(todoEnabled({ GENTLE_PI_TODO: "0" }), false);
73
+ assert.equal(todoEnabled({ GENTLE_PI_AGENTS_CHILD: "1" }), false);
74
+ assert.equal(todoCollapseKey({}), "ctrl+shift+t");
75
+ assert.equal(todoCollapseKey({ GENTLE_PI_TODO_KEY: "alt+t" }), "alt+t");
76
+ assert.equal(todoCollapseKey({ GENTLE_PI_TODO_KEY: "off" }), undefined);
77
+ const off = fakePi();
78
+ gentleTodo(off.pi, { GENTLE_PI_TODO: "off" });
79
+ assert.equal(off.tools.size, 0);
80
+ });
81
+
82
+ test("the todo tool writes the list, shows the card after the call, and carries the snapshot in details", async () => {
83
+ const { pi, tools, fire } = fakePi();
84
+ gentleTodo(pi, {});
85
+ const { ctx, widget } = fakeContext();
86
+ await fire("session_start", ctx);
87
+ assert.equal(widget(), undefined, "no card without tasks");
88
+
89
+ const tool = tools.get("todo")!;
90
+ const result = await tool.execute("c1", { action: "write", tasks: [{ title: "Write the parser", status: "in_progress", note: "parsing" }, { title: "Add tests" }] }, undefined, undefined, ctx);
91
+ assert.match(result.content[0].text, /2 tasks · 0 done · 1 in progress/);
92
+ assert.equal((result.details.gentleTodo as { tasks: unknown[] }).tasks.length, 2);
93
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
94
+ const lines = widget()!;
95
+ assert.match(lines[0], /^╭─ ❀ Todos · 0 of 2 ─+╮$/);
96
+ assert.match(lines[1], /◐ Write the parser · parsing/);
97
+ assert.match(lines[2], /○ Add tests/);
98
+ assert.equal(lines[lines.length - 1], "", "a blank line keeps the card off the prompt");
99
+
100
+ const bad = await tool.execute("c2", { action: "update", id: 9, status: "done" }, undefined, undefined, ctx);
101
+ assert.match(bad.content[0].text, /Error: no task #9/);
102
+ assert.equal(bad.details.error, "no task #9");
103
+ assert.match(tool.renderCall({ action: "write" }, plainTheme).render(40).join(""), /❀ todo · write/);
104
+ assert.equal(tool.renderResult({ content: [{ type: "text", text: "a\nb" }] }, { expanded: false }, plainTheme).render(40).join("|").trimEnd(), "a");
105
+ });
106
+
107
+ test("every turn carries the open tasks in the system prompt and the card goes stale after two silent turns", async () => {
108
+ const { pi, tools, fire } = fakePi();
109
+ gentleTodo(pi, {});
110
+ const { ctx, widget } = fakeContext();
111
+ await fire("session_start", ctx);
112
+ await fire("before_agent_start", ctx, { systemPrompt: "base" });
113
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
114
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
115
+
116
+ const withTasks = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string };
117
+ assert.match(withTasks.systemPrompt, /^base\n\n## Todo list/);
118
+ assert.match(withTasks.systemPrompt, /1\. \[pending\] Fix the bug/);
119
+ assert.doesNotMatch(widget()![0], /stale/);
120
+
121
+ const stale = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string };
122
+ assert.match(stale.systemPrompt, /stale: 2 turns without an update/);
123
+ assert.match(widget()![0], /stale · 2 turns/);
124
+
125
+ await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
126
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
127
+ assert.doesNotMatch(widget()![0], /stale/);
128
+ });
129
+
130
+ test("a finished list stays for its turn and clears at the next, and the collapse key folds the card", async () => {
131
+ const { pi, tools, shortcuts, fire } = fakePi();
132
+ gentleTodo(pi, {});
133
+ const { ctx, widget } = fakeContext();
134
+ await fire("session_start", ctx);
135
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A", status: "in_progress" }, { title: "B" }] }, undefined, undefined, ctx);
136
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
137
+ await shortcuts.get("ctrl+shift+t")!.handler(ctx);
138
+ assert.equal(widget()!.length, 4, "collapsed: top, one row, bottom, spacer");
139
+ assert.match(widget()![1], /◐ A/);
140
+ await shortcuts.get("ctrl+shift+t")!.handler(ctx);
141
+ assert.equal(widget()!.length, 5);
142
+
143
+ await tools.get("todo")!.execute("c2", { action: "write", tasks: [{ id: 1, title: "A", status: "done" }, { id: 2, title: "B", status: "done" }] }, undefined, undefined, ctx);
144
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
145
+ await fire("agent_end", ctx);
146
+ assert.match(widget()![0], /Todos · 2 of 2/, "the finished list is still visible at the end of its turn");
147
+ const next = await fire("before_agent_start", ctx, { systemPrompt: "base" });
148
+ assert.equal(next, undefined, "nothing open, nothing to add to the prompt");
149
+ assert.equal(widget(), undefined, "the card clears at the next turn");
150
+ });
151
+
152
+ test("session_start replays the list from the branch, rpiv-todo results included, and counts past turns", async () => {
153
+ const { pi, fire } = fakePi();
154
+ gentleTodo(pi, {});
155
+ const branch = [
156
+ { type: "message", message: { role: "user", content: "hi" } },
157
+ { type: "message", message: { role: "toolResult", toolName: "todo", isError: false, details: { action: "create", params: {}, tasks: [{ id: 1, subject: "Old task", status: "in_progress", activeForm: "still going" }], nextId: 2 } } },
158
+ { type: "message", message: { role: "user", content: "again" } },
159
+ ];
160
+ const { ctx, widget } = fakeContext(branch);
161
+ await fire("session_start", ctx);
162
+ const lines = widget()!;
163
+ assert.match(lines[0], /Todos · 0 of 1/);
164
+ assert.match(lines[1], /◐ Old task · still going/);
165
+ const headless = fakeContext(branch, false);
166
+ await fire("session_start", headless.ctx);
167
+ assert.equal(headless.widget(), undefined);
168
+ });
169
+
170
+ test("session_start drops a list that was already finished, so a reload never shows stale done work", async () => {
171
+ const { pi, fire } = fakePi();
172
+ gentleTodo(pi, {});
173
+ const finished = [
174
+ { type: "message", message: { role: "user", content: "hi" } },
175
+ { type: "message", message: { role: "toolResult", toolName: "todo", isError: false, details: { gentleTodo: { tasks: [{ id: 1, title: "Done A", status: "done" }, { id: 2, title: "Done B", status: "done" }], nextId: 3, updatedTurn: 1 } } } },
176
+ ];
177
+ const { ctx, widget } = fakeContext(finished);
178
+ await fire("session_start", ctx);
179
+ assert.equal(widget(), undefined, "nothing to show after a reload of finished work");
180
+ const prompt = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string } | undefined;
181
+ assert.equal(prompt, undefined, "and nothing is injected into the prompt");
182
+ });
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+
6
+ const repoRoot = join(import.meta.dirname, "..");
7
+ const skill = readFileSync(join(repoRoot, "skills", "issue-creation", "SKILL.md"), "utf8");
8
+
9
+ test("preserves the prefixed skill identity and complete Issue Form metadata", () => {
10
+ assert.match(skill, /^name: gentle-ai-issue-creation$/m);
11
+ assert.match(skill, /^description: ".+"$/m);
12
+ assert.match(skill, /^ version: "1\.3"$/m);
13
+ });
14
+
15
+ test("makes YAML Issue Forms the deterministic automated format authority", () => {
16
+ assert.match(skill, /YAML Issue Forms are the format authority/i);
17
+ assert.match(skill, /`input`, `textarea`, `dropdown`, and `checkboxes`/);
18
+ assert.match(skill, /declared order/i);
19
+ assert.match(skill, /visible labels and options/i);
20
+ assert.match(skill, /multi-select.*declared options order/i);
21
+ assert.match(skill, /optional dropdown[\s\S]{0,180}_No response_/i);
22
+ assert.match(skill, /Markdown controls are non-answer guidance/i);
23
+ assert.match(skill, /visible instructions[\s\S]{0,180}adjacent answers/i);
24
+ assert.match(skill, /do not render them as response sections/i);
25
+ assert.match(skill, /individually required checkbox/i);
26
+ assert.match(skill, /explicit first-person affirmation/i);
27
+ assert.match(skill, /textarea\.attributes\.render/);
28
+ assert.match(skill, /fence the answer with the declared language/i);
29
+ });
30
+
31
+ test("fails closed before publication for invalid required Issue Form data", () => {
32
+ assert.match(skill, /Fail closed before mutation/i);
33
+ assert.match(skill, /malformed, unsupported, missing, or ambiguous required structure or answers/i);
34
+ assert.match(skill, /malformed schema[\s\S]{0,200}do not open a browser/i);
35
+ assert.match(skill, /Never invent answers, selections, confirmations, or labels/i);
36
+ });
37
+
38
+ test("publishes reviewed Issue Form bodies through a private body file", () => {
39
+ assert.match(skill, /private `BODY_FILE`/);
40
+ assert.match(skill, /gh issue create --repo "\$TARGET" --title "\$TITLE" --body-file "\$BODY_FILE"/);
41
+ assert.match(skill, /target verification/i);
42
+ assert.match(skill, /duplicate search/i);
43
+ assert.match(skill, /Before commenting on a confirmed duplicate[\s\S]{0,250}privacy scan\/redaction[\s\S]{0,250}exact comment body/i);
44
+ assert.match(skill, /privacy scan/i);
45
+ assert.match(skill, /permitted labels/i);
46
+ assert.match(skill, /one mutation attempt/i);
47
+ assert.match(skill, /target-host read-back/i);
48
+ });
49
+
50
+ function assertBrowserContract(candidate: string) {
51
+ assert.match(candidate, /gh issue create --repo "\$TARGET" --title "\$TITLE" --body-file "\$BODY_FILE"/);
52
+ assert.match(
53
+ candidate,
54
+ /user explicitly requests browser completion[\s\S]{0,350}syntactically valid selected form cannot safely\/faithfully be represented by the automated path/i,
55
+ );
56
+ assert.match(candidate, /malformed schema[\s\S]{0,200}do not open a browser/i);
57
+ assert.match(candidate, /missing or ambiguous required[\s\S]{0,200}fail closed[\s\S]{0,200}do not open a browser/i);
58
+ assert.match(candidate, /browser handoff[\s\S]{0,160}never proof of publication/i);
59
+ assert.doesNotMatch(candidate, /Do not parse or render[\s\S]{0,300}stop for human completion/i);
60
+ assert.doesNotMatch(candidate, /(?:always|must|only)\s+(?:open|use|route to)\s+(?:the )?browser(?:\s+for)?\s+(?:Issue Forms?|YAML)/i);
61
+ assert.doesNotMatch(candidate, /YAML Issue Forms require human completion in the web issue chooser\./i);
62
+ assert.doesNotMatch(candidate, /Always route Issue Forms to the browser\./i);
63
+ assert.doesNotMatch(
64
+ candidate,
65
+ /\bfor\s+(?:YAML\s+)?Issue\s+Forms?\b[\s\S]{0,160}\b(?:open|use|route to|go to)\b[\s\S]{0,100}\b(?:browser|web issue chooser|web)\b/i,
66
+ );
67
+ }
68
+
69
+ test("keeps automated and conditional browser Issue Form paths distinct", () => {
70
+ assertBrowserContract(skill);
71
+
72
+ const unsafeMutations = [
73
+ "For YAML Issue Forms, open the web issue chooser and stop for human completion.",
74
+ "YAML Issue Forms require human completion in the web issue chooser.",
75
+ "Always route Issue Forms to the browser.",
76
+ ];
77
+ for (const mutation of unsafeMutations) {
78
+ assert.throws(() => assertBrowserContract(`${skill}\n\n${mutation}`), mutation);
79
+ }
80
+ });
81
+
82
+ test("discovers support routing and keeps all issue files private until cleanup", () => {
83
+ assert.match(skill, /README\.md/);
84
+ assert.match(skill, /\.github\/ISSUE_TEMPLATE\/config\.yml/);
85
+ assert.match(skill, /Discussions\/contact routing/i);
86
+ assert.match(skill, /questions\/support[\s\S]{0,250}(?:Discussions|contact)[\s\S]{0,250}otherwise ask or stop/i);
87
+ assert.doesNotMatch(skill, /TMP_DIR="\$\(mktemp -d\)"/);
88
+ assert.match(skill, /TMPDIR=\/tmp mktemp -d/);
89
+ assert.match(skill, /REPO_ROOT="\$\(git rev-parse --show-toplevel\)"/);
90
+ assert.match(skill, /REPO_ROOT="\$\(cd "\$REPO_ROOT" && pwd -P\)"/);
91
+ assert.match(skill, /if \[ "\$REPO_ROOT" = "\/" \]/);
92
+ assert.match(skill, /trap 'rm -rf -- "\$TMP_DIR"' EXIT/);
93
+ assert.match(skill, /TMP_DIR_REAL="\$\(cd "\$TMP_DIR" && pwd -P\)"/);
94
+ assert.match(skill, /case "\$TMP_DIR_REAL\/" in[\s\S]{0,200}"\$REPO_ROOT\/"\*/);
95
+ assert.match(skill, /if \[ "\$REPO_ROOT" = "\/" \][\s\S]{0,200}Temporary directory is inside the repository/);
96
+ assert.match(skill, /REPO_ROOT="\$\(cd "\$REPO_ROOT" && pwd -P\)"[\s\S]{0,200}TMPDIR=\/tmp mktemp -d/);
97
+ assert.match(skill, /TMPDIR=\/tmp mktemp -d[\s\S]{0,200}trap 'rm -rf -- "\$TMP_DIR"' EXIT/);
98
+ assert.match(skill, /chmod 700 "\$TMP_DIR_REAL"/);
99
+ assert.match(skill, /umask 077/);
100
+ assert.match(skill, /BODY_FILE="\$TMP_DIR_REAL\//);
101
+ assert.match(skill, /READBACK_FILE="\$TMP_DIR_REAL\//);
102
+ assert.doesNotMatch(skill, /TMP_DIR="\$\(cd "\$TMP_DIR" && pwd -P\)"/);
103
+ });
@@ -0,0 +1,202 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { mock } from "node:test";
3
+ import {
4
+ KeybindingsManager, stripTerminalSequences, Text, TUI_KEYBINDINGS,
5
+ type Component, type TuiMouseEvent, visibleWidth,
6
+ } from "@earendil-works/pi-tui";
7
+ import { NativeChoiceList } from "../lib/native-choice-list.ts";
8
+ import {
9
+ NativePointerScope,
10
+ type NativePointerRegionCallbacks,
11
+ } from "../lib/native-pointer-region.ts";
12
+ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
13
+
14
+ const selectedForeground = "\u001b[38;5;39m";
15
+ const hoverBackground = "\u001b[48;5;236m";
16
+ const theme = {
17
+ selectedPrefix: (text: string) => `${selectedForeground}${text}\u001b[39m`,
18
+ selectedText: (text: string) => `${selectedForeground}${text}\u001b[39m`,
19
+ description: (text: string) => `\u001b[38;5;244m${text}\u001b[39m`,
20
+ hoverBackground: (text: string) => `${hoverBackground}${text}\u001b[49m`,
21
+ };
22
+
23
+ function event(
24
+ type: TuiMouseEvent["type"], button: TuiMouseEvent["button"],
25
+ y: number, width: number, height: number, wheelDelta?: number,
26
+ ): TuiMouseEvent {
27
+ return {
28
+ type, button, x: 0, y, screenX: 0, screenY: y, width, height,
29
+ shift: false, alt: false, ctrl: false, wheelDelta,
30
+ };
31
+ }
32
+
33
+ function layout(
34
+ list: NativeChoiceList<{ id: string; label: string; description: string }>, width: number, text: string,
35
+ ) {
36
+ const lines = list.render(width);
37
+ assert.ok(lines.every((line) => visibleWidth(line) <= width), "ANSI-stripped lines fit the current render width");
38
+ const row = lines.findIndex((line) => stripTerminalSequences(line).includes(text));
39
+ assert.ok(row >= 0, "the current wrapped layout locates the requested row");
40
+ return { lines, row };
41
+ }
42
+
43
+ test("native choice list presents one hovered wrapped option without changing selection", () => {
44
+ const list = new NativeChoiceList([
45
+ {
46
+ id: "same", label: "Duplicate label",
47
+ description: "A wrapped description that spans more than one display line at this width.",
48
+ },
49
+ {
50
+ id: "other", label: "Duplicate label",
51
+ description: "The second item has the same label but a distinct stable identity.",
52
+ },
53
+ ], theme);
54
+ const narrow = layout(list, 40, "second item");
55
+ assert.equal(list.handleMouse(event("move", "none", narrow.row, 40, narrow.lines.length))?.render, true);
56
+ const narrowHover = list.render(40);
57
+ assert.ok(
58
+ narrowHover.every((line, index) => index >= narrow.row - 1 || !line.includes(hoverBackground)),
59
+ "only the hovered option rows have a background",
60
+ );
61
+ assert.ok(
62
+ narrowHover.some((line) => line.includes(selectedForeground) && !line.includes(hoverBackground)),
63
+ "keyboard selection has a distinct foreground",
64
+ );
65
+ assert.equal(list.handleMouse(event("move", "none", narrow.row, 40, narrow.lines.length))?.render, false);
66
+ assert.equal(list.getSelectedItem()?.id, "same");
67
+
68
+ const wider = layout(list, 56, "second item");
69
+ assert.doesNotMatch(
70
+ wider.lines.join("\n"),
71
+ new RegExp(hoverBackground.replace(/[\[\]]/g, "\\$&")), "resize clears hover",
72
+ );
73
+ assert.equal(list.handleMouse(event("move", "none", wider.row, 56, wider.lines.length))?.render, true);
74
+ const hovered = list.render(56);
75
+ assert.ok(hovered.some((line) => line.includes(hoverBackground)), "real SGR background marks the second option");
76
+ const firstRow = hovered.findIndex((line) => stripTerminalSequences(line).includes("Duplicate label"));
77
+ assert.ok(firstRow >= 0);
78
+ list.handleMouse(event("move", "none", firstRow, 56, hovered.length));
79
+ const selectedHover = list.render(56)[firstRow];
80
+ assert.ok(
81
+ selectedHover?.includes(selectedForeground) && selectedHover.includes(hoverBackground),
82
+ "selected and hovered styles combine",
83
+ );
84
+ list.invalidate();
85
+ assert.doesNotMatch(list.render(56).join("\n"), new RegExp(hoverBackground.replace(/[\[\]]/g, "\\$&")));
86
+ });
87
+
88
+ test("native choice list ignores Kitty key releases", () => {
89
+ const list = new NativeChoiceList(
90
+ [{ id: "first", label: "First" }, { id: "second", label: "Second" }],
91
+ theme, new KeybindingsManager(TUI_KEYBINDINGS),
92
+ );
93
+ const selected: string[] = [];
94
+ let cancelled = 0;
95
+ list.onSelect = (item) => selected.push(item.id);
96
+ list.onCancel = () => { cancelled++; };
97
+ list.handleInput("\u001b[B");
98
+ list.handleInput("\r");
99
+ assert.deepEqual(selected, ["second"], "legacy arrow and Enter use semantic bindings");
100
+ list.setSelectedIndex(0);
101
+ selected.length = 0;
102
+ list.handleInput("\u001b[1;1:3B");
103
+ assert.equal(list.getSelectedItem()?.id, "first", "Kitty arrow release does not navigate");
104
+ list.handleInput("\u001b[1;1:1B");
105
+ assert.equal(list.getSelectedItem()?.id, "second", "Kitty arrow press navigates");
106
+ list.setSelectedIndex(0);
107
+ list.handleInput("\u001b[1;1:2B");
108
+ assert.equal(list.getSelectedItem()?.id, "second", "Kitty arrow repeat still navigates");
109
+ list.handleInput("\u001b[13;1:3u");
110
+ list.handleInput("\u001b[27;1:3u");
111
+ assert.deepEqual([selected, cancelled], [[], 0], "Kitty confirm and cancel releases do not activate callbacks");
112
+ list.handleInput("\u001b[13;1:1u");
113
+ list.handleInput("\u001b[27;1:1u");
114
+ assert.deepEqual([selected, cancelled], [["second"], 1], "Kitty confirm and cancel presses still activate");
115
+ });
116
+
117
+ test("root observer clears hover and disabled controls ignore every input", () => {
118
+ const list = new NativeChoiceList([
119
+ { id: "first", label: "First choice", description: "First detail." },
120
+ { id: "second", label: "Second choice", description: "Second detail." },
121
+ ], theme);
122
+ let renders = 0;
123
+ let selected = 0;
124
+ let cancelled = 0;
125
+ list.onSelect = () => { selected++; };
126
+ list.onCancel = () => { cancelled++; };
127
+ const root = createNativeFullscreenInteraction({
128
+ keyboardTarget: list, requestRender: () => { renders++; },
129
+ mouseObserver: list.createMouseObserver(() => { renders++; }),
130
+ });
131
+ root.addChild(new Text("Header", 0, 0));
132
+ root.addChild(list);
133
+ root.addChild(new Text("Footer", 0, 0));
134
+ const lines = root.render(48);
135
+ const firstRow = lines.findIndex((line) => stripTerminalSequences(line).includes("First"));
136
+ const secondRow = lines.findIndex((line) => stripTerminalSequences(line).includes("Second"));
137
+ assert.ok(firstRow >= 0 && secondRow >= 0);
138
+ root.handleMouse(event("move", "none", secondRow, 48, lines.length));
139
+ assert.match(root.render(48).join("\n"), new RegExp(hoverBackground.replace(/[\[\]]/g, "\\$&")));
140
+ root.handleMouse(event("move", "none", 0, 48, lines.length));
141
+ assert.doesNotMatch(root.render(48).join("\n"), new RegExp(hoverBackground.replace(/[\[\]]/g, "\\$&")));
142
+ assert.equal(renders, 1);
143
+ root.handleMouse(event("move", "none", 0, 48, lines.length));
144
+ assert.equal(renders, 1);
145
+ list.handleMouse(event("move", "none", secondRow, 48, lines.length));
146
+ list.setDisabled(true);
147
+ assert.doesNotMatch(
148
+ root.render(48).join("\n"),
149
+ new RegExp(hoverBackground.replace(/[\[\]]/g, "\\$&")), "disabling clears hover",
150
+ );
151
+ const before = list.getSelectedItem()?.id;
152
+ for (const [type, button, delta] of [
153
+ ["move", "none"], ["press", "left"],
154
+ ["click", "left"], ["wheel", "none", 1],
155
+ ] as const) {
156
+ assert.equal(
157
+ root.handleMouse(event(type, button, firstRow, 48, lines.length, delta)), undefined,
158
+ );
159
+ }
160
+ for (const input of ["\r", "\u001b"]) list.handleInput(input);
161
+ assert.deepEqual(
162
+ [list.getSelectedItem()?.id, selected, cancelled], [before, 0, 0],
163
+ "disabled mouse and keyboard events cannot mutate or activate",
164
+ );
165
+ });
166
+
167
+ test("empty native choice lists do not activate phantom selections", () => {
168
+ const list = new NativeChoiceList([], theme);
169
+ let selected = 0;
170
+ let cancelled = 0;
171
+ list.onSelect = () => { selected++; };
172
+ list.onCancel = () => { cancelled++; };
173
+ list.handleInput("\r");
174
+ list.handleInput("\u001b");
175
+ assert.equal(selected, 0);
176
+ assert.equal(cancelled, 1);
177
+ });
178
+
179
+ test("native choice list composes rows through the shared pointer scope", () => {
180
+ const originalWrap = NativePointerScope.prototype.wrap;
181
+ let wraps = 0;
182
+ const spy = mock.method(NativePointerScope.prototype, "wrap", function (
183
+ this: NativePointerScope, child: Component, callbacks: NativePointerRegionCallbacks,
184
+ ) {
185
+ wraps++;
186
+ return originalWrap.call(this, child, callbacks);
187
+ });
188
+ try {
189
+ const list = new NativeChoiceList([
190
+ { id: "first", label: "First" },
191
+ { id: "second", label: "Second" },
192
+ ], theme);
193
+ const lines = list.render(40);
194
+ assert.equal(wraps, 2, "each choice row uses the composable shared pointer primitive");
195
+ const second = lines.findIndex((line) => line.includes("Second"));
196
+ assert.ok(second >= 0);
197
+ assert.equal(list.handleMouse(event("move", "none", second, 40, lines.length))?.render, true);
198
+ assert.equal(list.getSelectedItem()?.id, "first", "hover remains distinct from keyboard selection");
199
+ } finally {
200
+ spy.mock.restore();
201
+ }
202
+ });
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { mock } from "node:test";
3
+ import { Container, MouseRegion, type TuiMouseEvent, SelectList, Text } from "@earendil-works/pi-tui";
4
+ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
5
+
6
+ const items = [
7
+ { value: "first", label: "First option", description: "A description keeps the list layout realistic." },
8
+ { value: "second", label: "Second option", description: "A second description verifies the target row." },
9
+ ];
10
+
11
+ const theme = {
12
+ selectedPrefix: (text: string) => text,
13
+ selectedText: (text: string) => text,
14
+ description: (text: string) => text,
15
+ scrollInfo: (text: string) => text,
16
+ noMatch: (text: string) => text,
17
+ };
18
+
19
+ function mouse(
20
+ type: TuiMouseEvent["type"],
21
+ button: TuiMouseEvent["button"],
22
+ y: number,
23
+ height: number,
24
+ wheelDelta?: number,
25
+ ): TuiMouseEvent {
26
+ return {
27
+ type,
28
+ button,
29
+ x: 0,
30
+ y,
31
+ screenX: 0,
32
+ screenY: y,
33
+ width: 80,
34
+ height,
35
+ shift: false,
36
+ alt: false,
37
+ ctrl: false,
38
+ wheelDelta,
39
+ };
40
+ }
41
+
42
+ test("native fullscreen interaction preserves observer order and native dispatch identity", () => {
43
+ const order: string[] = [];
44
+ const root = createNativeFullscreenInteraction({
45
+ keyboardTarget: new Text("keyboard", 0, 0),
46
+ requestRender() {},
47
+ mouseObserver: { beforeMouse: () => order.push("before"), afterMouse: () => order.push("after") },
48
+ });
49
+ root.addChild(new Text("before child", 0, 0));
50
+ root.addChild(new MouseRegion(new Text("native child", 0, 0), () => {
51
+ order.push("native child");
52
+ return { handled: true, focus: true };
53
+ }));
54
+ root.addChild(new Text("after child", 0, 0));
55
+ const lines = root.render(80);
56
+ const row = lines.findIndex((line) => line.includes("native child"));
57
+ assert.ok(row >= 0);
58
+ const nativeHandleMouse = Container.prototype.handleMouse;
59
+ let nativeResult: ReturnType<typeof nativeHandleMouse>;
60
+ const spy = mock.method(Container.prototype, "handleMouse", function (this: Container, event: TuiMouseEvent) {
61
+ nativeResult = nativeHandleMouse.call(this, event);
62
+ return nativeResult;
63
+ });
64
+ try {
65
+ const result = root.handleMouse(mouse("press", "left", row, lines.length));
66
+ assert.deepEqual(order, ["before", "native child", "after"]);
67
+ assert.equal(result, nativeResult, "the observer returns the exact native Container result");
68
+ assert.equal(result?.focusTarget, root, "native focus ownership remains with the root");
69
+ } finally {
70
+ spy.mock.restore();
71
+ }
72
+ });
73
+
74
+ test("native fullscreen interaction preserves Container geometry and SelectList behavior", () => {
75
+ const list = new SelectList(items, items.length, theme);
76
+ const selected: string[] = [];
77
+ let renders = 0;
78
+ list.onSelect = (item) => selected.push(item.value);
79
+ const root = createNativeFullscreenInteraction({
80
+ keyboardTarget: list,
81
+ requestRender: () => { renders++; },
82
+ });
83
+ root.addChild(new Text("A long header wraps before the SelectList and must not define hit rows.", 1, 0));
84
+ root.addChild(list);
85
+
86
+ assert.ok(root instanceof Container);
87
+ assert.ok(root.render(20).length > items.length, "the narrow render includes a wrapped header");
88
+ const lines = root.render(80);
89
+ const secondRow = lines.findIndex((line) => line.includes("Second option"));
90
+ assert.ok(secondRow >= 0, "the test locates the actual rendered SelectList row");
91
+
92
+ const wheel = root.handleMouse(mouse("wheel", "none", secondRow, lines.length, 1));
93
+ assert.equal(wheel?.handled, true);
94
+ assert.equal(wheel?.render, true);
95
+ assert.deepEqual(selected, [], "wheel never activates an option");
96
+ assert.equal(
97
+ root.handleMouse(mouse("wheel", "none", secondRow, lines.length, 1))?.render,
98
+ false,
99
+ "wheel clamps at the final item",
100
+ );
101
+ assert.equal(root.handleMouse(mouse("move", "none", secondRow, lines.length)), undefined);
102
+ assert.equal(list.getSelectedItem()?.value, "second", "move does not change native selection");
103
+
104
+ root.handleInput("\u001b[A");
105
+ assert.equal(renders, 1, "keyboard input forwards to the native control and requests a render");
106
+ assert.equal(list.getSelectedItem()?.value, "first", "keyboard selection remains independent of pointer movement");
107
+ assert.deepEqual(selected, [], "keyboard navigation does not activate an option");
108
+
109
+ const press = root.handleMouse(mouse("press", "left", secondRow, lines.length));
110
+ assert.equal(press?.handled, true);
111
+ assert.equal(press?.focus, true);
112
+ assert.equal(press?.focusTarget, root, "the root remains the keyboard focus owner");
113
+ assert.deepEqual(selected, [], "press never activates an option");
114
+ assert.equal(root.handleMouse(mouse("release", "left", secondRow, lines.length)), undefined);
115
+ assert.equal(root.handleMouse(mouse("click", "right", secondRow, lines.length)), undefined);
116
+ assert.equal(root.handleMouse(mouse("click", "left", lines.length, lines.length)), undefined);
117
+
118
+ root.handleInput("\r");
119
+ assert.equal(renders, 2, "Enter renders after activating the focused native item");
120
+ assert.deepEqual(selected, ["second"], "Enter activates the item selected by press");
121
+
122
+ const click = root.handleMouse(mouse("click", "left", secondRow, lines.length));
123
+ assert.equal(click?.handled, true);
124
+ assert.deepEqual(selected, ["second", "second"]);
125
+ });