Skip to content

Commit 98dfd07

Browse files
authored
Merge pull request #61 from llblab/dev
0.15.0: Companion Status Lines
2 parents 5eecb1f + 73269ad commit 98dfd07

10 files changed

Lines changed: 183 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
No open changes.
66

7+
## 0.15.0: Companion Status Lines
8+
9+
- `[API]` Added `registerTelegramStatusLineProvider()` on the public `/status` subpath so companion extensions can append compact rows to the `/start` menu status text. Providers are synchronous, model-aware, isolated on failure, and rendered with Telegram-style capitalized labels. Impact: quota/status widgets can progressively enhance the Telegram operator menu without owning polling, transport, or core menu rendering.
10+
- `[Docs]` Documented the status-line provider with an abstract companion-extension example and listed `pi-codex-usage` as a companion extension. Impact: the public API docs stay implementation-neutral while the README still points operators to the concrete Codex quota widget.
11+
712
## 0.14.0: Direct Telegram Delivery, Queue Semantics, And Section Diagnostics
813

914
- `[Prompt Guidance]` Tightened agent context for Telegram buttons: use normal Markdown plus top-level hidden `telegram_button` comments, never JSON button specs or standalone button actions, and keep comments out of code/quotes/lists/indented examples. Impact: agents immediately know how to author visible Telegram text, inline buttons, and direct `telegram_message` payloads without transport hacks.

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ Unknown inline-button callbacks are forwarded to π as `[callback] <data>` when
209209

210210
### Extension Sections
211211

212-
Ordinary pi extensions can register structured UI sections that appear in the main Telegram menu and Settings submenu without owning a second polling loop. Each section gets a narrow typed context with `edit`, `open`, `enqueuePrompt`, `answerCallback`, and `callbackData()` — enough to build interactive Telegram-native surfaces while `pi-telegram` owns transport, callback routing, navigation hierarchy, and diagnostics.
212+
Ordinary pi extensions can register structured UI sections that appear in the main Telegram menu and Settings submenu without owning a second polling loop. Companion extensions can also register compact status lines for the `/start` menu status text, allowing widgets such as quota indicators to appear beside Status, Usage, Cost, and Context only when relevant to the active model. Each section gets a narrow typed context with `edit`, `open`, `enqueuePrompt`, `answerCallback`, and `callbackData()` — enough to build interactive Telegram-native surfaces while `pi-telegram` owns transport, callback routing, navigation hierarchy, and diagnostics.
213213

214214
Import `registerTelegramSection()` from `@llblab/pi-telegram/sections` and return a disposer on shutdown. Sections can send interactive messages directly into the chat via `ctx.open()` — confirmation dialogs, approve/deny gates, and multi-step forms live outside the menu hierarchy while callbacks route through the same typed handler. See [`@llblab/pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) for a working reference and the [Extension Sections Standard](./docs/sections.md) for the full contract.
215215

@@ -260,6 +260,12 @@ Modes are `hidden`, `always`, and `interval`. `hidden` means no time line is add
260260

261261
Third-party extensions that integrate with `pi-telegram`:
262262

263+
- [`pi-codex-usage`](https://github.com/llblab/pi-codex-usage) — Compact Codex subscription quota/status widget for the Pi statusline and the inline menu status text opened by `/start`.
264+
265+
```bash
266+
pi install npm:@llblab/pi-codex-usage
267+
```
268+
263269
- [`pi-telegram-tool-status`](https://github.com/Timur00Kh/pi-telegram-tool-status) — Live-updating service messages that list tools used by the agent. It keeps one message per Telegram prompt and edits it in place as tools execute.
264270

265271
```bash

api/status.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Public Telegram status API
3+
* Zones: package boundary, companion extension interop
4+
* Exposes compact status-menu line registration for companion extensions while keeping status rendering internals package-private
5+
*/
6+
7+
export {
8+
registerTelegramStatusLineProvider,
9+
type TelegramStatusLineProvider,
10+
type TelegramStatusLineProviderContext,
11+
type TelegramStatusLineProviderResult,
12+
} from "../lib/status.ts";

docs/public-api.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Preferred public imports:
1616
```ts
1717
import telegram from "@llblab/pi-telegram";
1818
import { registerTelegramSection } from "@llblab/pi-telegram/sections";
19+
import { registerTelegramStatusLineProvider } from "@llblab/pi-telegram/status";
1920
import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
2021
import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
2122
import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
@@ -102,6 +103,9 @@ High-level stable APIs:
102103
- `registerTelegramSection()`
103104
- Identity: required `id`.
104105
- Purpose: managed menu/settings UI surfaces.
106+
- `registerTelegramStatusLineProvider()`
107+
- Identity: required `id`.
108+
- Purpose: compact companion status rows in the `/start` menu status text.
105109
- `registerTelegramVoiceTranscriptionProvider()`
106110
- Identity: required stable `id` for new code.
107111
- Purpose: STT fallback for voice/audio input.
@@ -165,6 +169,27 @@ Contract:
165169

166170
Full behavior: [Extension Sections](./sections.md).
167171

172+
## Status Lines
173+
174+
Import from `@llblab/pi-telegram/status`.
175+
176+
```ts
177+
const off = registerTelegramStatusLineProvider(
178+
({ activeModel }) => {
179+
if (activeModel?.provider !== "example-provider") return undefined;
180+
return { label: "service", value: "ready" };
181+
},
182+
{ id: "@scope/example-status" },
183+
);
184+
```
185+
186+
Contract:
187+
188+
- Providers are synchronous because `/start` status text is rendered inline with the menu.
189+
- Return `undefined` when the line is not relevant for the active model.
190+
- Provider failures are isolated and skipped so optional companion status cannot break the core Telegram menu.
191+
- The bridge renders rows as `<Label>: <value>` in the same HTML status block as Status, Usage, Cost, and Context, capitalizing the first label character for Telegram UI consistency.
192+
168193
## Updates
169194

170195
Import from `@llblab/pi-telegram/updates`.

lib/status.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,24 @@ interface TelegramContextUsage {
3636
}
3737

3838
export interface TelegramStatusActiveModel {
39+
provider?: string;
40+
id?: string;
3941
contextWindow?: number;
4042
}
4143

44+
export interface TelegramStatusLineProviderContext {
45+
activeModel: TelegramStatusActiveModel | undefined;
46+
}
47+
48+
export interface TelegramStatusLineProviderResult {
49+
label: string;
50+
value: string;
51+
}
52+
53+
export type TelegramStatusLineProvider = (
54+
ctx: TelegramStatusLineProviderContext,
55+
) => TelegramStatusLineProviderResult | undefined;
56+
4257
export interface TelegramStatusContext {
4358
sessionManager: { getEntries(): TelegramStatusSessionEntry[] };
4459
getContextUsage(): TelegramContextUsage | undefined;
@@ -52,6 +67,8 @@ export interface TelegramStatusContext {
5267

5368
export type TelegramRuntimeEventDetailValue = string | number | boolean | null;
5469

70+
const TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY =
71+
"__piTelegramStatusLineProviders__";
5572
const MAX_RECENT_TELEGRAM_RUNTIME_EVENTS = 10;
5673
const MAX_TELEGRAM_RUNTIME_EVENT_MESSAGE_LENGTH = 1000;
5774
const MAX_TELEGRAM_RUNTIME_EVENT_DETAIL_LENGTH = 1000;
@@ -166,7 +183,10 @@ export interface TelegramStatusRuntime<
166183
getStatusLines: () => string[];
167184
}
168185

169-
function truncateTelegramRuntimeEventText(text: string, maxLength: number): string {
186+
function truncateTelegramRuntimeEventText(
187+
text: string,
188+
maxLength: number,
189+
): string {
170190
if (text.length <= maxLength) return text;
171191
return `${text.slice(0, maxLength).trimEnd()}… [truncated ${text.length - maxLength} chars]`;
172192
}
@@ -262,6 +282,61 @@ export function recordTelegramRuntimeEvent(
262282
recordStructuredTelegramRuntimeEvent(events, { category, error }, options);
263283
}
264284

285+
function getOrCreateTelegramStatusLineProviderRegistry(): Map<
286+
string,
287+
TelegramStatusLineProvider
288+
> {
289+
const existing = (globalThis as Record<string, unknown>)[
290+
TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY
291+
];
292+
if (existing instanceof Map)
293+
return existing as Map<string, TelegramStatusLineProvider>;
294+
const registry = new Map<string, TelegramStatusLineProvider>();
295+
(globalThis as Record<string, unknown>)[
296+
TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY
297+
] = registry;
298+
return registry;
299+
}
300+
301+
/**
302+
* Register a compact companion-extension line for the Telegram status menu.
303+
*
304+
* Providers are synchronous and should return undefined when their line is not
305+
* relevant for the active model. Errors are isolated so optional companion
306+
* status cannot break the core Telegram menu.
307+
*/
308+
export function registerTelegramStatusLineProvider(
309+
provider: TelegramStatusLineProvider,
310+
options: { id: string },
311+
): () => void {
312+
const registry = getOrCreateTelegramStatusLineProviderRegistry();
313+
registry.set(options.id, provider);
314+
return () => {
315+
if (registry.get(options.id) === provider) registry.delete(options.id);
316+
};
317+
}
318+
319+
export function getTelegramStatusLineProviderResults(
320+
ctx: TelegramStatusLineProviderContext,
321+
): TelegramStatusLineProviderResult[] {
322+
const results: TelegramStatusLineProviderResult[] = [];
323+
const registry = getOrCreateTelegramStatusLineProviderRegistry();
324+
for (const provider of registry.values()) {
325+
try {
326+
const result = provider(ctx);
327+
if (!result?.label || !result.value) continue;
328+
results.push(result);
329+
} catch {
330+
continue;
331+
}
332+
}
333+
return results;
334+
}
335+
336+
export function clearTelegramStatusLineProviders(): void {
337+
getOrCreateTelegramStatusLineProviderRegistry().clear();
338+
}
339+
265340
export function createTelegramRuntimeEventRecorder(
266341
options: TelegramRuntimeEventRecorderOptions,
267342
): TelegramRuntimeEventRecorder {
@@ -314,7 +389,9 @@ function formatTelegramRuntimeEvent(event: TelegramRuntimeEvent): string {
314389
return `${new Date(event.at).toISOString()} ${formatTelegramRuntimeEventSummary(event)}`;
315390
}
316391

317-
function buildTelegramRuntimeEventSummary(events: TelegramRuntimeEvent[]): string {
392+
function buildTelegramRuntimeEventSummary(
393+
events: TelegramRuntimeEvent[],
394+
): string {
318395
const counts = new Map<string, number>();
319396
for (const event of events) {
320397
const category = formatTelegramRuntimeEventCategory(event);
@@ -553,8 +630,13 @@ function collectUsageStats(ctx: TelegramStatusContext): TelegramUsageStats {
553630
return stats;
554631
}
555632

633+
function formatStatusRowLabel(label: string): string {
634+
if (!label) return label;
635+
return `${label[0]?.toUpperCase() ?? ""}${label.slice(1)}`;
636+
}
637+
556638
function buildStatusRow(label: string, value: string): string {
557-
return `<b>${escapeHtml(label)}:</b> <code>${escapeHtml(value)}</code>`;
639+
return `<b>${escapeHtml(formatStatusRowLabel(label))}:</b> <code>${escapeHtml(value)}</code>`;
558640
}
559641

560642
function buildUsageSummary(stats: TelegramUsageStats): string | undefined {
@@ -613,5 +695,8 @@ export function buildStatusHtml(
613695
lines.push(buildStatusRow("Cost", costSummary));
614696
}
615697
lines.push(buildStatusRow("Context", buildContextSummary(ctx, activeModel)));
698+
for (const row of getTelegramStatusLineProviderResults({ activeModel })) {
699+
lines.push(buildStatusRow(row.label, row.value));
700+
}
616701
return lines.join("\n");
617702
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@llblab/pi-telegram",
3-
"version": "0.14.0",
3+
"version": "0.15.0",
44
"private": false,
55
"publishConfig": {
66
"access": "public"
@@ -50,6 +50,7 @@
5050
"./outbound": "./api/outbound.ts",
5151
"./updates": "./api/updates.ts",
5252
"./sections": "./api/sections.ts",
53+
"./status": "./api/status.ts",
5354
"./voice": "./api/voice.ts",
5455
"./keyboard": "./api/keyboard.ts"
5556
},

tests/invariants.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ test("Package exports expose only stable public domains", () => {
166166
"./outbound": "./api/outbound.ts",
167167
"./updates": "./api/updates.ts",
168168
"./sections": "./api/sections.ts",
169+
"./status": "./api/status.ts",
169170
"./voice": "./api/voice.ts",
170171
"./keyboard": "./api/keyboard.ts",
171172
});

tests/public-api.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ async function assertPackagePathNotExported(specifier: string): Promise<void> {
1818
}
1919

2020
test("Public package subpaths expose the stable companion-extension API", async () => {
21-
const [root, inbound, outbound, updates, sections, voice, keyboard] =
21+
const [root, inbound, outbound, updates, sections, status, voice, keyboard] =
2222
await Promise.all([
2323
import("@llblab/pi-telegram"),
2424
import("@llblab/pi-telegram/inbound"),
2525
import("@llblab/pi-telegram/outbound"),
2626
import("@llblab/pi-telegram/updates"),
2727
import("@llblab/pi-telegram/sections"),
28+
import("@llblab/pi-telegram/status"),
2829
import("@llblab/pi-telegram/voice"),
2930
import("@llblab/pi-telegram/keyboard"),
3031
]);
@@ -44,6 +45,9 @@ test("Public package subpaths expose the stable companion-extension API", async
4445
"getTelegramSectionDiagnostics",
4546
"registerTelegramSection",
4647
]);
48+
assert.deepEqual(Object.keys(status).sort(), [
49+
"registerTelegramStatusLineProvider",
50+
]);
4751
assert.deepEqual(Object.keys(voice).sort(), [
4852
"TELEGRAM_VOICE_REPLY_MODES",
4953
"computeVoicePromptContribution",

tests/status.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import {
1010
buildTelegramBridgeStatusLines,
1111
buildTelegramRuntimeEventLines,
1212
buildTelegramStatusBarText,
13+
clearTelegramStatusLineProviders,
1314
createTelegramBridgeStatusRuntime,
1415
createTelegramRuntimeEventRecorder,
1516
createTelegramStatusHtmlBuilder,
1617
createTelegramStatusRuntime,
1718
getTelegramStatusBarProcessingStatus,
1819
recordStructuredTelegramRuntimeEvent,
1920
recordTelegramRuntimeEvent,
21+
registerTelegramStatusLineProvider,
2022
type TelegramRuntimeEvent,
2123
} from "../lib/status.ts";
2224

@@ -384,6 +386,40 @@ test("Status HTML builder binds active model lookup", () => {
384386
assert.match(html, /Context.*0\.0%\/1\.0k/s);
385387
});
386388

389+
test("Status HTML builder includes companion status lines", () => {
390+
clearTelegramStatusLineProviders();
391+
const unregisterCodex = registerTelegramStatusLineProvider(
392+
({ activeModel }) =>
393+
activeModel?.contextWindow === 1000
394+
? { label: "codex", value: "████ 23.7h" }
395+
: undefined,
396+
{ id: "@scope/codex" },
397+
);
398+
const unregisterBroken = registerTelegramStatusLineProvider(
399+
() => {
400+
throw new Error("optional provider failed");
401+
},
402+
{ id: "@scope/broken" },
403+
);
404+
try {
405+
const buildStatusHtml = createTelegramStatusHtmlBuilder({
406+
getActiveModel: () => ({ contextWindow: 1000 }),
407+
});
408+
const html = buildStatusHtml({
409+
sessionManager: { getEntries: () => [] },
410+
getContextUsage: () => ({ percent: 0, contextWindow: undefined }),
411+
isIdle: () => true,
412+
modelRegistry: { isUsingOAuth: () => false },
413+
});
414+
assert.match(html, /Context.*0\.0%\/1\.0k/s);
415+
assert.match(html, /Codex.* 23\.7h/s);
416+
} finally {
417+
unregisterCodex();
418+
unregisterBroken();
419+
clearTelegramStatusLineProviders();
420+
}
421+
});
422+
387423
test("Status HTML builder shows compacting while compact is running", () => {
388424
const buildStatusHtml = createTelegramStatusHtmlBuilder({
389425
getActiveModel: () => undefined,

0 commit comments

Comments
 (0)