Skip to content

Commit 12e27f7

Browse files
committed
Upgrade /ledger to interactive TUI overlay and wire tui.ts indicators in index.ts
1 parent 35900e6 commit 12e27f7

1 file changed

Lines changed: 111 additions & 38 deletions

File tree

index.ts

Lines changed: 111 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
*/
1414

1515
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
17+
import {
18+
Container,
19+
type SelectItem,
20+
SelectList,
21+
Text,
22+
} from "@earendil-works/pi-tui";
1623
import { createState, resetState, type AgenticodingState } from "./state.js";
1724
import { CONTEXT_PRIMER } from "./system-prompt.js";
1825
import { buildNudge, registerWatchdog } from "./watchdog.js";
@@ -22,40 +29,12 @@ import { registerHandoffTool } from "./handoff/tool.js";
2229
import { registerHandoffCommand } from "./handoff/command.js";
2330
import { registerHandoffCompaction } from "./handoff/compact.js";
2431
import { registerSpawnTool } from "./spawn/index.js";
25-
26-
/** Build a status bar preview from ledger entries. */
27-
function formatLedgerPreview(state: AgenticodingState): string {
28-
const names = Array.from(state.ledger.keys()).sort();
29-
if (names.length === 0) return "(empty)";
30-
return names
31-
.map((name) => {
32-
const content = state.ledger.get(name)!;
33-
const firstLine = (content.split("\n")[0] ?? "").slice(0, 60);
34-
return `${name}: ${firstLine}`;
35-
})
36-
.join("\n");
37-
}
38-
39-
/** Update TUI indicators: context usage + ledger count. */
40-
function updateIndicators(ctx: ExtensionContext, state: AgenticodingState): void {
41-
if (!ctx.hasUI) return;
42-
43-
const theme = ctx.ui.theme;
44-
45-
// Context usage
46-
const usage = ctx.getContextUsage();
47-
if (usage && usage.percent !== null) {
48-
const pct = Math.round(usage.percent);
49-
const tone = pct >= 70 ? "error" : pct >= 50 ? "warning" : pct >= 30 ? "accent" : "dim";
50-
ctx.ui.setStatus("agenticoding-ctx", theme.fg("dim", "ctx ") + theme.fg(tone, `${pct}%`));
51-
} else {
52-
ctx.ui.setStatus("agenticoding-ctx", theme.fg("dim", "ctx --%"));
53-
}
54-
55-
// Ledger count
56-
const count = state.ledger.size;
57-
ctx.ui.setStatus("agenticoding-ledger", count > 0 ? `\u{1F4D2} ${count}` : "");
58-
}
32+
import {
33+
STATUS_KEY_HANDOFF,
34+
WIDGET_KEY_WARNING,
35+
updateIndicators,
36+
} from "./tui.js";
37+
import { formatEntryPreview } from "./ledger/store.js";
5938

6039
export default function (pi: ExtensionAPI): void {
6140
const state: AgenticodingState = createState();
@@ -73,12 +52,93 @@ export default function (pi: ExtensionAPI): void {
7352
// ── Register commands ───────────────────────────────────────────
7453
registerHandoffCommand(pi, state);
7554

76-
// ── /ledger command — show entries in overlay ───────────────────
55+
// ── /ledger command — interactive entry selector ────────────────
7756
pi.registerCommand("ledger", {
78-
description: "Show ledger entries with name, line count, and first-line preview",
57+
description: "Select a ledger entry to preview",
7958
handler: async (_args, ctx) => {
80-
const preview = formatLedgerPreview(state);
81-
ctx.ui.notify(`Ledger (${state.ledger.size} entries):\n${preview}`, "info");
59+
if (!ctx.hasUI) {
60+
return;
61+
}
62+
63+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
64+
const container = new Container();
65+
66+
container.addChild(
67+
new DynamicBorder((s: string) => theme.fg("accent", s)),
68+
);
69+
container.addChild(
70+
new Text(theme.fg("accent", theme.bold(` Ledger (${state.ledger.size} entries) `)), 1, 0),
71+
);
72+
73+
const entries = Array.from(state.ledger.entries()).sort(([a], [b]) => a.localeCompare(b));
74+
let selectList: SelectList | undefined;
75+
let finished = false;
76+
77+
if (entries.length === 0) {
78+
container.addChild(
79+
new Text(theme.fg("dim", " (empty) — use ledger_add to create entries"), 1, 0),
80+
);
81+
} else {
82+
const items: SelectItem[] = entries.map(([name, content]) => ({
83+
value: name,
84+
label: name,
85+
description: formatEntryPreview(content),
86+
}));
87+
88+
selectList = new SelectList(items, Math.min(items.length, 10), {
89+
selectedPrefix: (t) => theme.fg("accent", t),
90+
selectedText: (t) => theme.fg("accent", t),
91+
description: (t) => theme.fg("muted", t),
92+
scrollInfo: (t) => theme.fg("dim", t),
93+
noMatch: (t) => theme.fg("warning", t),
94+
});
95+
selectList.onSelect = ({ value }) => {
96+
// Guard: selectList is set to undefined below, so this handler
97+
// cannot fire twice — no re-entrancy guard needed here.
98+
const body = state.ledger.get(value);
99+
if (!body) { done(); return; }
100+
// Switch to body view: show the selected entry body inline
101+
container.clear();
102+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
103+
container.addChild(new Text(theme.fg("accent", theme.bold(` ${value} `)), 1, 0));
104+
const truncated = body.length > 500 ? body.slice(0, 500) + "\n..." : body;
105+
container.addChild(new Text(theme.fg("toolOutput", truncated), 1, 0));
106+
container.addChild(new Text(theme.fg("dim", " press any key to close "), 1, 0));
107+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
108+
selectList = undefined;
109+
tui.requestRender();
110+
};
111+
selectList.onCancel = () => {
112+
if (finished) return;
113+
finished = true;
114+
done();
115+
};
116+
container.addChild(selectList);
117+
}
118+
119+
container.addChild(
120+
new Text(theme.fg("dim", entries.length === 0
121+
? " esc close "
122+
: " \u2191\u2195 navigate \u2022 enter select \u2022 esc close "), 1, 0),
123+
);
124+
container.addChild(
125+
new DynamicBorder((s: string) => theme.fg("accent", s)),
126+
);
127+
128+
return {
129+
render: (w) => container.render(w),
130+
invalidate: () => container.invalidate(),
131+
handleInput: (data) => {
132+
if (finished) return;
133+
if (!selectList) { finished = true; done(); return; }
134+
selectList.handleInput?.(data);
135+
// Conservative: always repaint after key input.
136+
// SelectList.handleInput returns void in the current API,
137+
// so we can't conditionally skip — the cost is negligible.
138+
tui.requestRender();
139+
},
140+
};
141+
});
82142
},
83143
});
84144

@@ -138,12 +198,25 @@ export default function (pi: ExtensionAPI): void {
138198
pi.on("session_start", async (event, ctx: ExtensionContext) => {
139199
if (event.reason === "new") {
140200
resetState(state);
201+
// Clear any stale TUI indicators from the previous session
202+
if (ctx.hasUI) {
203+
ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined);
204+
ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined);
205+
}
141206
}
142207
updateIndicators(ctx, state);
143208
});
144209

145210
// ── update TUI indicators after each turn ───────────────────────
146211
pi.on("turn_end", async (_event, ctx: ExtensionContext) => {
212+
// Fallback: clear handoff indicator if the LLM completed a turn
213+
// without calling the handoff tool (ignored the direction)
214+
if (state.pendingRequestedHandoff && !state.pendingRequestedHandoff.toolCalled) {
215+
state.pendingRequestedHandoff = null;
216+
if (ctx.hasUI) {
217+
ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined);
218+
}
219+
}
147220
updateIndicators(ctx, state);
148221
});
149222
}

0 commit comments

Comments
 (0)