Skip to content

Commit 80191aa

Browse files
authored
refactor: implement event-driven status bar architecture with ticket stats (#21)
* refactor(ticket): migrate status bar rendering to event-driven architecture * refactor(ticket): simplify session creation and message handling * docs(ticket): expand description guidance for self-contained tickets * docs: update changelog
1 parent bcceef3 commit 80191aa

4 files changed

Lines changed: 98 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,56 @@ Each `.ts` file in `pi-extensions/` must own exactly **one** concern:
5151

5252
If an extension grows beyond ~400 lines, split it. If it does two unrelated things, split it.
5353

54-
### Strict Isolation — No Cross-Extension Dependencies
54+
### Strict Isolation — No Cross-Extension Imports
5555

56-
Extensions must **not** import from or depend on other extensions in this repo. Each extension is self-contained.
56+
Extensions must **not** import from or depend on other extensions in this repo. Each extension is self-contained at the module level.
5757

5858
**Wrong:**
5959
```typescript
6060
// plan-ask.ts
61-
import { askQuestion } from "./kbrainstorm"; // ❌ Cross-extension dependency
61+
import { askQuestion } from "./kbrainstorm"; // ❌ Cross-extension import
6262
```
6363

6464
**Right:**
6565
Each extension registers its own tools/commands. If two extensions need the same capability, extract it into a shared utility in a `lib/` directory, or better yet, make each extension independently register what it needs.
6666

67+
### Cross-Extension Communication via `pi.events`
68+
69+
Extensions communicate at runtime through the shared event bus (`pi.events`), never through imports. Data-producing extensions emit typed events; consuming extensions listen.
70+
71+
```typescript
72+
// producer (ticket extension)
73+
pi.events.emit("ticket:stats", { total: 14, epics: 1, open: 12, ... });
74+
75+
// consumer (status-bar extension)
76+
pi.events.on("ticket:stats", (data) => { ticketStats = data; });
77+
```
78+
79+
### Status Bar Rendering Architecture
80+
81+
`status-bar.ts` is the **sole owner of footer rendering**. Other extensions must **not** call `ctx.ui.setStatus()` to display information in the footer. Instead:
82+
83+
1. **Data extensions** (e.g., `ticket/`) compute stats and emit them via `pi.events` using a namespaced event (e.g., `"ticket:stats"`).
84+
2. **`status-bar.ts`** listens for these events, stores the data, and renders it in the footer alongside model info, context meter, git stats, and tool tallies.
85+
86+
The status bar **may be aware of specific data shapes** (e.g., ticket stats interface) to render them with appropriate formatting, icons, and color coding. This is intentional — the status bar is a UI orchestrator, not a generic passthrough.
87+
88+
**Wrong:**
89+
```typescript
90+
// ticket extension
91+
ctx.ui.setStatus("ticket", "🎫 14 tickets"); // ❌ Extension rendering its own footer status
92+
```
93+
94+
**Right:**
95+
```typescript
96+
// ticket extension — emit raw data
97+
pi.events.emit("ticket:stats", { total: 14, epics: 1, open: 12, inProgress: 1, closed: 1 });
98+
99+
// status-bar.ts — owns the rendering
100+
pi.events.on("ticket:stats", (data) => { ticketStats = data; });
101+
// ... renders ticketStats in footer with proper theme colors
102+
```
103+
67104
### Naming Conventions
68105

69106
- **File names**: lowercase kebab-case matching the primary command or tool name (e.g., `status-bar.ts`, `notify.ts`).

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ All notable changes to agent-stuff are documented here.
3838

3939

4040

41-
## feat/aggregate-ticket-statuses
41+
42+
43+
## refactor/ticket-event-driven-status
44+
45+
This PR introduces an **event-driven architecture for ticket status rendering**, decoupling the ticket extension from direct footer manipulation (#21). The ticket extension now emits structured `ticket:stats` events via `pi.events` instead of calling `ctx.ui.setStatus()`, while the status-bar extension listens and renders rich ticket metrics (epics, tasks, bugs, features, status breakdowns) with theme-aware formatting. Supporting changes improve ticket description guidance to ensure self-contained documentation and simplify session creation logic by separating session initialization from message dispatch. The refactor reinforces architectural isolation—extensions communicate only through the shared event bus, never through cross-extension imports.
46+
47+
## [1.0.9](https://github.com/kostyay/agent-stuff/pull/20) - 2026-03-03
4248

4349
The status bar now aggregates multiple ticket statuses into a single count display (e.g., "3 tickets") rather than listing each ticket individually (#20). This improvement reduces visual clutter in the extension's footer display while maintaining visibility of ticket status information. The change filters ticket statuses separately from other extension statuses and applies proper pluralization, making the status bar more readable when dealing with multiple concurrent tickets.
4450

pi-extensions/status-bar.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,28 @@ interface GitDiffStats {
103103
unstagedFiles: number;
104104
}
105105

106+
/** Ticket statistics emitted by the ticket extension via pi.events. */
107+
interface TicketStats {
108+
total: number;
109+
epics: number;
110+
tasks: number;
111+
bugs: number;
112+
features: number;
113+
open: number;
114+
inProgress: number;
115+
closed: number;
116+
}
117+
106118
/** Status bar extension — registers a rich two-line custom footer. */
107119
export default function statusBarExtension(pi: ExtensionAPI) {
108120
const counts: Record<string, number> = {};
109121
let turnCount = 0;
110122
let agentActive = false;
111123

124+
// Ticket stats (populated via pi.events from ticket extension)
125+
let ticketStats: TicketStats | null = null;
126+
pi.events.on("ticket:stats", (data: TicketStats) => { ticketStats = data; });
127+
112128
// Cached git diff stats (refreshed on tool_execution_end for write/edit/bash)
113129
let diffStats: GitDiffStats | null = null;
114130
let diffStatsTimer: ReturnType<typeof setTimeout> | null = null;
@@ -257,20 +273,24 @@ export default function statusBarExtension(pi: ExtensionAPI) {
257273
const statuses = footerData.getExtensionStatuses();
258274
const sandboxStatus = statuses.get("sandbox");
259275
const otherStatuses = [...statuses.entries()]
260-
.filter(([key]) => key !== "sandbox");
261-
262-
// Count ticket statuses separately; show as "N tickets"
263-
const ticketCount = otherStatuses.filter(([, val]) => val === "ticket").length;
264-
const nonTicketStatuses = otherStatuses
265-
.filter(([, val]) => val !== "ticket")
276+
.filter(([key]) => key !== "sandbox")
266277
.map(([, val]) => val);
267-
if (ticketCount > 0) {
268-
nonTicketStatuses.push(`${ticketCount} ticket${ticketCount > 1 ? "s" : ""}`);
278+
279+
// Build ticket status segment from event data
280+
if (ticketStats && ticketStats.total > 0) {
281+
const parts: string[] = [];
282+
if (ticketStats.epics > 0) parts.push(theme.fg("accent", `${ticketStats.epics}E`));
283+
const nonEpic = ticketStats.tasks + ticketStats.bugs + ticketStats.features;
284+
if (nonEpic > 0) parts.push(theme.fg("muted", `${nonEpic}T`));
285+
if (ticketStats.inProgress > 0) parts.push(theme.fg("warning", `${ticketStats.inProgress}🔵`));
286+
if (ticketStats.open > 0) parts.push(theme.fg("dim", `${ticketStats.open}⚪`));
287+
if (ticketStats.closed > 0) parts.push(theme.fg("success", `${ticketStats.closed}✅`));
288+
otherStatuses.push(`🎫 ${parts.join(" ")}`);
269289
}
270290

271291
let l1Mid = "";
272-
if (nonTicketStatuses.length > 0) {
273-
l1Mid = " " + nonTicketStatuses.join(theme.fg("dim", " · "));
292+
if (otherStatuses.length > 0) {
293+
l1Mid = " " + otherStatuses.join(theme.fg("dim", " · "));
274294
}
275295

276296
const pad1 = " ".repeat(

pi-extensions/ticket/index.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -607,16 +607,23 @@ export default function ticketExtension(pi: ExtensionAPI) {
607607
function refreshUI(ctx: ExtensionContext): void {
608608
const dir = getTicketsDir(ctx.cwd);
609609
const tickets = listTicketsSync(dir);
610-
const inProgress = tickets.filter((t) => t.status === "in_progress");
611-
const remaining = tickets.filter((t) => t.status !== "closed").length;
612610

613-
// Status
614-
if (!tickets.length) {
615-
ctx.ui.setStatus("🎫 no tickets", "ticket");
616-
} else {
617-
ctx.ui.setStatus(`🎫 ${tickets.length} tickets (${remaining} remaining)`, "ticket");
611+
// Single-pass counting for ticket stats
612+
const stats = { epics: 0, tasks: 0, bugs: 0, features: 0, open: 0, inProgress: 0, closed: 0 };
613+
for (const t of tickets) {
614+
if (t.type === "epic") stats.epics++;
615+
else if (t.type === "task") stats.tasks++;
616+
else if (t.type === "bug") stats.bugs++;
617+
else if (t.type === "feature") stats.features++;
618+
if (t.status === "open") stats.open++;
619+
else if (t.status === "in_progress") stats.inProgress++;
620+
else if (t.status === "closed") stats.closed++;
618621
}
619622

623+
pi.events.emit("ticket:stats", { total: tickets.length, ...stats });
624+
625+
const inProgress = tickets.filter((t) => t.status === "in_progress");
626+
620627
// Widget: current in-progress ticket
621628
if (inProgress.length) {
622629
ctx.ui.setWidget("ticket-current", (_tui, theme) => {
@@ -1167,7 +1174,10 @@ export default function ticketExtension(pi: ExtensionAPI) {
11671174
"**Step 3: Create** — Once I approve the plan, create the tickets:\n" +
11681175
"1. `ticket create` the epic (type=epic) with description and acceptance criteria\n" +
11691176
"2. `ticket create` each task (type=task, parent=<epic-id>) with:\n" +
1170-
" - description: what to implement\n" +
1177+
" - description: **self-contained** — include enough project context, design decisions, " +
1178+
"and relevant details from the planning discussion so an agent can work on this ticket " +
1179+
"in an isolated session without access to the original conversation. " +
1180+
"Reference specific files, APIs, data structures, and conventions by name.\n" +
11711181
" - acceptance: definition of done\n" +
11721182
" - tests: specific test criteria that must pass before closing\n" +
11731183
" - priority and deps as planned\n" +
@@ -1234,20 +1244,15 @@ export default function ticketExtension(pi: ExtensionAPI) {
12341244
`Steps:\n1. \`ticket start ${firstTicket.id}\`\n2. Implement the work\n3. \`ticket close ${firstTicket.id}\`${record.tests ? " (with tests_confirmed=true after verifying tests)" : ""}\n\n` +
12351245
`After closing, there are ${ready.length - 1} more tickets to process.`;
12361246

1237-
// For fork-each, create a new session with the prompt
1247+
// Fork a new session, then send the prompt to trigger execution
12381248
const result = await ctx.newSession({
12391249
parentSession: ctx.sessionManager.getSessionFile(),
1240-
setup: async (sm) => {
1241-
sm.appendMessage({
1242-
role: "user",
1243-
content: [{ type: "text", text: prompt }],
1244-
timestamp: Date.now(),
1245-
});
1246-
},
12471250
});
12481251

12491252
if (result.cancelled) {
12501253
ctx.ui.notify("Session creation cancelled", "info");
1254+
} else {
1255+
pi.sendUserMessage(prompt);
12511256
}
12521257
},
12531258
});

0 commit comments

Comments
 (0)