Skip to content

Commit 2ed421b

Browse files
authored
Merge pull request #689 from code-yeongyu/feat/shared-rule-activation-tui
feat(coding-agent): render shared rule activations
2 parents 04665bf + b150296 commit 2ed421b

13 files changed

Lines changed: 425 additions & 5 deletions

File tree

packages/coding-agent/src/core/extensions/builtin/changes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Builtin extensions changes
22

3+
## rule-activation: shared project-rules and TTSR notices (2026-08-04)
4+
5+
- Added `rule-activation/` as a presentation-only builtin module with a typed discriminated activation contract, defensive persisted-data parser, custom-entry append/registration helpers, and a compact/expandable Box/Text renderer.
6+
- Project-rules and TTSR both register the same renderer so either extension still works when loaded alone. Project-rules records successful dynamic tool-path matches; TTSR records committed remediation while preserving its separate persistence entry and hidden model nudge.
7+
- Why shared code is required: the two engines retain incompatible discovery, matching, deduplication, and remediation semantics, but the TUI needs one stable durable-entry contract instead of engine-specific raw transcript text.
8+
- Coverage: `test/rules-before-agent-start.test.ts`, `test/ttsr/extension-wiring.test.ts`, and `test/suite/rule-activation-renderer.test.ts`.
9+
- Expected merge conflict zones: the new `rule-activation/` directory and the small registration/append seams in `rules/index.ts` and `ttsr/index.ts`. Do not fold engine policy into the shared module during conflict resolution.
10+
311
## service-tier: enable fast mode for Codex API extension providers (2026-08-03)
412

513
- `/fast` now checks the model's `openai-codex-responses` API capability instead
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { ExtensionAPI } from "../../types.ts";
2+
import { renderRuleActivationEntry } from "./renderer.ts";
3+
import { RULE_ACTIVATION_ENTRY_TYPE, type RuleActivationDetails } from "./types.ts";
4+
5+
export {
6+
type ProjectRulesActivationDetails,
7+
RULE_ACTIVATION_ENTRY_TYPE,
8+
type RuleActivationDetails,
9+
type TtsrActivationDetails,
10+
} from "./types.ts";
11+
12+
export function registerRuleActivationRenderer(pi: ExtensionAPI): void {
13+
pi.registerEntryRenderer(RULE_ACTIVATION_ENTRY_TYPE, renderRuleActivationEntry);
14+
}
15+
16+
export function appendRuleActivation(pi: ExtensionAPI, details: RuleActivationDetails): void {
17+
pi.appendEntry(RULE_ACTIVATION_ENTRY_TYPE, details);
18+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Box, Text } from "@earendil-works/pi-tui";
2+
import type { EntryRenderer } from "../../types.ts";
3+
import { parseRuleActivationDetails, type RuleActivationDetails } from "./types.ts";
4+
5+
const BOLD = "\u001b[1m";
6+
const BOLD_OFF = "\u001b[22m";
7+
8+
export const renderRuleActivationEntry: EntryRenderer<unknown> = (entry, options, theme) => {
9+
const details = parseRuleActivationDetails(entry.data);
10+
if (details === undefined) return undefined;
11+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
12+
box.addChild(new Text(theme.fg("accent", `${BOLD}${titleLine(details)}${BOLD_OFF}`), 0, 0));
13+
box.addChild(new Text(theme.fg("dim", summaryLine(details)), 0, 0));
14+
if (options.expanded) {
15+
box.addChild(new Text(theme.fg("dim", detailLine(details)), 0, 0));
16+
}
17+
return box;
18+
};
19+
20+
function titleLine(details: RuleActivationDetails): string {
21+
switch (details.kind) {
22+
case "project-rules":
23+
return `● Project rules · ${details.targetPath}`;
24+
case "ttsr":
25+
return `⚠ Stream rule · ${details.owner}`;
26+
}
27+
}
28+
29+
function summaryLine(details: RuleActivationDetails): string {
30+
switch (details.kind) {
31+
case "project-rules": {
32+
const noun = details.rules.length === 1 ? "instruction" : "instructions";
33+
return `${details.rules.length} ${noun} matched and injected for this tool result.`;
34+
}
35+
case "ttsr":
36+
return details.remediation === "nudge"
37+
? "Output interrupted; a corrective nudge was queued."
38+
: "Corrupted generation discarded; bounded provider retry started.";
39+
}
40+
}
41+
42+
function detailLine(details: RuleActivationDetails): string {
43+
switch (details.kind) {
44+
case "project-rules":
45+
return details.rules.map((rule) => `rule ${rule}`).join("\n");
46+
case "ttsr":
47+
return `remediation ${details.remediation} · observed ${details.rules.join(", ")}`;
48+
}
49+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
export const RULE_ACTIVATION_ENTRY_TYPE = "rule-activation";
2+
3+
export interface ProjectRulesActivationDetails {
4+
readonly kind: "project-rules";
5+
readonly targetPath: string;
6+
readonly rules: readonly string[];
7+
}
8+
9+
export interface TtsrActivationDetails {
10+
readonly kind: "ttsr";
11+
readonly owner: string;
12+
readonly rules: readonly string[];
13+
readonly remediation: "nudge" | "provider-error";
14+
}
15+
16+
export type RuleActivationDetails = ProjectRulesActivationDetails | TtsrActivationDetails;
17+
18+
export function parseRuleActivationDetails(value: unknown): RuleActivationDetails | undefined {
19+
if (!isObject(value)) return undefined;
20+
const kind = Reflect.get(value, "kind");
21+
switch (kind) {
22+
case "project-rules":
23+
return parseProjectRulesActivation(value);
24+
case "ttsr":
25+
return parseTtsrActivation(value);
26+
default:
27+
return undefined;
28+
}
29+
}
30+
31+
function parseProjectRulesActivation(value: object): ProjectRulesActivationDetails | undefined {
32+
const targetPath = Reflect.get(value, "targetPath");
33+
const rules = stringList(Reflect.get(value, "rules"));
34+
if (typeof targetPath !== "string" || targetPath.length === 0 || rules === undefined) return undefined;
35+
return { kind: "project-rules", targetPath, rules };
36+
}
37+
38+
function parseTtsrActivation(value: object): TtsrActivationDetails | undefined {
39+
const owner = Reflect.get(value, "owner");
40+
const rules = stringList(Reflect.get(value, "rules"));
41+
const remediation = Reflect.get(value, "remediation");
42+
if (
43+
typeof owner !== "string" ||
44+
owner.length === 0 ||
45+
rules === undefined ||
46+
(remediation !== "nudge" && remediation !== "provider-error")
47+
) {
48+
return undefined;
49+
}
50+
return { kind: "ttsr", owner, rules, remediation };
51+
}
52+
53+
function stringList(value: unknown): readonly string[] | undefined {
54+
if (
55+
!Array.isArray(value) ||
56+
value.length === 0 ||
57+
!value.every((entry) => typeof entry === "string" && entry.length > 0)
58+
) {
59+
return undefined;
60+
}
61+
return value;
62+
}
63+
64+
function isObject(value: unknown): value is object {
65+
return typeof value === "object" && value !== null && !Array.isArray(value);
66+
}

packages/coding-agent/src/core/extensions/builtin/rules/changes.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
Vendored from [`code-yeongyu/pi-rules`](https://github.com/code-yeongyu/pi-rules) (see `external-versions.json`).
44

5+
## 2026-08-04 - Shared activation notices for dynamic rules
6+
7+
### What changed and why
8+
9+
- The vendored extension now registers Senpi's shared `rule-activation` entry renderer and appends a typed, display-only activation entry after a newly matched dynamic rule block is added to a tool result.
10+
- The notice records the tool target and matched rule paths so the TUI can show a compact summary and expandable details instead of presenting the injected instruction block as undifferentiated tool output.
11+
- Static `before_agent_start` delivery, dynamic fingerprint deduplication, and the exact model-facing instruction block are unchanged.
12+
13+
### Why this cannot stay upstream-only
14+
15+
- Upstream pi-rules owns matching and prompt delivery but does not own Senpi's custom-entry renderer registry or shared TTSR presentation layer. The adapter therefore belongs at the Senpi builtin boundary.
16+
17+
### Coverage and expected conflict zones
18+
19+
- Coverage: `test/rules-before-agent-start.test.ts` verifies unchanged dynamic model delivery plus the typed activation entry; `test/suite/rule-activation-renderer.test.ts` verifies standalone renderer registration and malformed persisted-data handling.
20+
- Expected conflicts: `index.ts` around renderer registration and the dynamic `tool_result` return path. Preserve the shared activation append after `markDynamicInjected(...)` and before returning augmented tool content.
21+
522
## Senpi adaptations vs upstream
623

724
- Imports rewritten by `scripts/vendor-transform.mjs`: `@mariozechner/pi-tui` -> `@earendil-works/pi-tui`; `@mariozechner/pi-coding-agent` symbols -> `../../types.ts` (and `Theme` -> `modes/interactive/theme/theme.ts`); relative `.js` import suffixes -> `.ts`.

packages/coding-agent/src/core/extensions/builtin/rules/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isAbsolute, relative } from "node:path";
33

44
import type { ExtensionAPI } from "../../types.ts";
55

6+
import { appendRuleActivation, registerRuleActivationRenderer } from "../rule-activation/index.ts";
67
import { registerSlashCommands } from "./commands.ts";
78
import { configFromEnvironment } from "./config.ts";
89
import { createEngine } from "./rules/engine.ts";
@@ -41,6 +42,7 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
4142
extractToolPaths,
4243
});
4344
registerSlashCommands(pi, engine);
45+
registerRuleActivationRenderer(pi);
4446

4547
function syncConfigFromFlags(): void {
4648
const disabled = pi.getFlag("pi-rules-disabled");
@@ -139,10 +141,16 @@ export default function piRulesExtension(pi: ExtensionAPI): void {
139141
}
140142

141143
const firstPendingTarget = pendingFingerprints[0]?.targetPath ?? firstTargetPath;
142-
const block = engine.formatDynamic(rules, displayPath(ctx.cwd, firstPendingTarget));
144+
const targetPath = displayPath(ctx.cwd, firstPendingTarget);
145+
const block = engine.formatDynamic(rules, targetPath);
143146
for (const rule of rules) {
144147
engine.markDynamicInjected(firstTargetPath, rule);
145148
}
149+
appendRuleActivation(pi, {
150+
kind: "project-rules",
151+
targetPath,
152+
rules: rules.map((rule) => rule.relativePath),
153+
});
146154

147155
return { content: [...event.content, { type: "text", text: block }] };
148156
});

packages/coding-agent/src/core/extensions/builtin/ttsr/changes.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# TTSR Fork Tracker
22

3+
## 2026-08-04 - Shared visible activation records
4+
5+
### What changed and why
6+
7+
- TTSR now registers Senpi's shared `rule-activation` entry renderer and appends a typed visible activation record whenever remediation is committed.
8+
- The new record reports the detector owner, observed rule ids, and whether the remediation used a hidden nudge or bounded provider-error retry.
9+
- The existing `ttsr-injection` persistence entry, hidden corrective `custom_message`, abort/truncation flow, provider retry, repeat gating, and session restoration are unchanged.
10+
11+
### Why an extension-local change is required
12+
13+
- TTSR remains the sole owner of the point where a detection becomes committed remediation. A generic TUI layer cannot infer that state safely from stream deltas or from the hidden nudge without coupling itself to the coordinator.
14+
- The shared module owns only typed presentation; TTSR still owns detection, interruption, transcript mutation, and retry policy.
15+
16+
### Coverage and expected conflict zones
17+
18+
- Coverage: `test/ttsr/extension-wiring.test.ts` verifies both remediation modes retain their existing records/messages and add the typed activation entry; `test/suite/rule-activation-renderer.test.ts` verifies standalone renderer registration and expanded TTSR details.
19+
- Expected conflicts: `index.ts` around extension registration and `recordInjection(...)`. Preserve both the original persistence append and the additional shared activation append.
20+
321
## 2026-07-31 - Interrupt fabricated unavailable-tool calls
422

523
### What changed and why

packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getKeybindings } from "@earendil-works/pi-tui";
22

33
import type { ExtensionAPI, ExtensionContext, MessageUpdateEvent } from "../../types.ts";
4+
import { appendRuleActivation, registerRuleActivationRenderer } from "../rule-activation/index.ts";
45
import { BUILTIN_TTSR_RULES } from "./builtin-rules.ts";
56
import { registerTtsrCommands, type TtsrPublicState } from "./commands.ts";
67
import { claimAbort, createGenerationState, markUserCancelled } from "./coordinator.ts";
@@ -53,6 +54,7 @@ function parseDisabledRules(raw: boolean | string | undefined): string[] {
5354
}
5455

5556
export default function ttsrExtension(pi: ExtensionAPI): void {
57+
registerRuleActivationRenderer(pi);
5658
pi.registerFlag("ttsr-disabled", {
5759
type: "boolean",
5860
default: false,
@@ -82,13 +84,19 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
8284
}
8385
}
8486

85-
function recordInjection(owner: string, observed: readonly string[], retryMode: string): void {
87+
function recordInjection(owner: string, observed: readonly string[], retryMode: "nudge" | "provider-error"): void {
8688
pi.appendEntry(TTSR_INJECTION_CUSTOM_TYPE, {
8789
rules: observed,
8890
owner,
8991
remediation: retryMode,
9092
at: Date.now(),
9193
});
94+
appendRuleActivation(pi, {
95+
kind: "ttsr",
96+
owner,
97+
rules: observed,
98+
remediation: retryMode,
99+
});
92100
}
93101

94102
function notify(ctx: ExtensionContext, owner: string): void {

packages/coding-agent/test/rules-before-agent-start.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,31 @@ import { createModelRegistry } from "./model-runtime-test-utils.ts";
2727

2828
const BASE_SYSTEM_PROMPT = "BASE SYSTEM PROMPT (rebuilt by the host on every user prompt)";
2929
const STATIC_BLOCK_HEADING = "## Project Instructions";
30+
const RULE_ACTIVATION_ENTRY_TYPE = "rule-activation";
31+
32+
interface AppendedEntry {
33+
readonly customType: string;
34+
readonly data: unknown;
35+
}
3036

3137
describe("rules builtin - before_agent_start delivery", () => {
3238
let projectDir: string;
3339
let canaryRulePath: string;
3440
let canaryRuleContents: string;
3541
let canaryToken: string;
42+
let dynamicRuleToken: string;
43+
let targetPath: string;
44+
let appendedEntries: AppendedEntry[];
3645
let sessionManager: SessionManager;
3746
let modelRegistry: ModelRegistry;
3847

3948
const extensionActions: ExtensionActions = {
4049
registerLazyToolActivator: () => {},
4150
sendMessage: () => {},
4251
sendUserMessage: () => {},
43-
appendEntry: () => {},
52+
appendEntry: (customType, data) => {
53+
appendedEntries.push({ customType, data });
54+
},
4455
setSessionName: () => {},
4556
getSessionName: () => undefined,
4657
setLabel: () => {},
@@ -123,7 +134,17 @@ describe("rules builtin - before_agent_start delivery", () => {
123134
canaryRuleContents = `---\nalwaysApply: true\n---\n\n# Canary rule\n\n${canaryToken}\n`;
124135
canaryRulePath = join(projectDir, ".omo", "rules", "canary.md");
125136
writeFileSync(canaryRulePath, canaryRuleContents, "utf-8");
137+
dynamicRuleToken = `RULES-DYNAMIC-CANARY-${randomUUID()}`;
138+
writeFileSync(
139+
join(projectDir, ".omo", "rules", "typescript.md"),
140+
`---\nglobs: src/**/*.ts\n---\n\n# TypeScript rule\n\n${dynamicRuleToken}\n`,
141+
"utf-8",
142+
);
143+
targetPath = join(projectDir, "src", "lib", "proxy", "strategy.ts");
144+
mkdirSync(join(projectDir, "src", "lib", "proxy"), { recursive: true });
145+
writeFileSync(targetPath, "export const strategy = true;\n", "utf-8");
126146

147+
appendedEntries = [];
127148
sessionManager = SessionManager.inMemory();
128149
modelRegistry = await createModelRegistry(AuthStorage.create(join(projectDir, "auth.json")));
129150
});
@@ -212,4 +233,39 @@ describe("rules builtin - before_agent_start delivery", () => {
212233
"a rule already present as a native context file must not be injected again",
213234
).not.toContain(canaryToken);
214235
});
236+
237+
it("#given a path-scoped rule #when a matching read tool result arrives #then the model block and display-only activation entry are both emitted", async () => {
238+
const runner = await createRunner();
239+
const prompt = await runner.emitBeforeAgentStart(
240+
"Read the target TypeScript file",
241+
undefined,
242+
BASE_SYSTEM_PROMPT,
243+
{
244+
cwd: projectDir,
245+
},
246+
);
247+
expect(prompt?.systemPrompt ?? "").not.toContain(dynamicRuleToken);
248+
249+
const toolResult = await runner.emitToolResult({
250+
type: "tool_result",
251+
toolName: "read",
252+
toolCallId: "call-read-strategy",
253+
input: { path: "src/lib/proxy/strategy.ts" },
254+
content: [{ type: "text", text: "export const strategy = true;" }],
255+
details: undefined,
256+
isError: false,
257+
});
258+
259+
expect(textOf(toolResult?.content)).toContain(dynamicRuleToken);
260+
expect(appendedEntries).toContainEqual(
261+
expect.objectContaining({
262+
customType: RULE_ACTIVATION_ENTRY_TYPE,
263+
data: expect.objectContaining({
264+
kind: "project-rules",
265+
targetPath: "src/lib/proxy/strategy.ts",
266+
rules: expect.arrayContaining([".omo/rules/typescript.md"]),
267+
}),
268+
}),
269+
);
270+
});
215271
});

0 commit comments

Comments
 (0)