From 9435ff709b8d49ba77baf7edf7b91c9aa810ef8e Mon Sep 17 00:00:00 2001 From: evandrodevbr Date: Thu, 1 Oct 2026 11:28:53 -0300 Subject: [PATCH] feat: render the status line as a themed widget in the TUI and document it omp strips ANSI from extension status text, so setStatus drops the theme colors. String widgets keep them: in the TUI the line is a belowEditor widget and the plain footer entry is cleared; other modes keep setStatus. --- CHANGELOG.md | 2 ++ README.md | 18 ++++++++++++++ lib/status.ts | 27 +++++++++++++++------ tests/status.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca226e8..126bd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- `Status Line`: The line under the omp editor now shows a state glyph, the bot name, received/sent message counters and the time of the last message (`telegram ● connected · @bot · ⤵12 ⤴11 · ⏱ 14:32`), plus the next step for non-working states (`/telegram-setup`, `send /start to @bot`, `/telegram-connect`). Glyphs follow omp's `symbolPreset`. omp strips ANSI from extension status text, so in the TUI the line is rendered as a `belowEditor` widget that keeps the theme colors; other modes keep `setStatus`. Counters come from the new leaf domain `lib/traffic.ts`: received on every owner message, sent on every successful `sendMessage`/`sendRichMessage`/media call, and `/telegram-status` gains a `- messages:` line. Impact: connection state, traffic and recovery steps are visible without opening `/telegram-status`. +- `Pairing`: The poll loop persisted the config object it captured when polling started. The store swaps in a fresh object on every persist, so after the initial sync that stale copy lacked later changes and the next offset commit erased the paired owner: `telegram.json` never kept `allowedUserId`, the bridge re-paired on every message and the status flipped back to `awaiting pairing`. The runner now carries only the poll offset onto the store's current config. Impact: first-time pairing sticks, and another Telegram user can no longer be paired by writing before the owner's next message. - `omp Agent Directory`: Resolve the agent directory from the host's own `getAgentDir()` (registered by the composition root via `setHostAgentDirResolver`) instead of sniffing `execPath`/`argv[1]` for an `omp` prefix. omp installed through Bun runs as `bun …/@oh-my-pi/pi-coding-agent/dist/cli.js`, which matched neither, so with `telegram.json` in `~/.omp/agent` the bridge never connected and wrote its log and bus socket under `~/.pi/agent`. `PI_CODING_AGENT_DIR` still wins; with no host registered the fallback stays `~/.pi/agent`. Impact: a bot token saved by `/telegram-setup` lives in omp's own agent directory and is found again. `lib/paths.ts` stays free of Pi SDK imports, which cost about a second of startup per importing process. - `omp Settled Boundary`: omp never emits `agent_settled`, the only place the activity runtime finalized a turn. On omp the first terminal `agent_end` (no `willContinue: true`, which omp sets for retries, compaction continuations, todo/plan reminders and async wakes) now drives `onAgentSettled`; hosts that emit `agent_settled` natively are unchanged. Impact: Activity API consumers receive `agent-settled` and the activity id no longer leaks across turns. - `omp Current Model`: omp never emits `model_select`, so the stored current model went stale after a local `/model` switch, and omp builds command contexts by spreading a base context, which freezes `ctx.model` at the moment of `/telegram-connect`. On omp the live `ctx.models.current()` now wins over both, and the stored value no longer overrides it. Impact: the Telegram model menu marks the model omp is actually using. diff --git a/README.md b/README.md index 4cdc1bb..361c3ac 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,24 @@ Config is written to `/telegram.json`. The agent directory is `PI_COD Profile names: lowercase ASCII letters and digits, up to 32 characters; `default`, `main` and `active` are reserved. Profiles keep isolated polling, diagnostics, Threaded Mode state and local bus transport. +### Status line + +The bridge keeps one live line under the `omp` editor. In the interactive TUI it is a themed widget (`omp` strips color from plain extension status text, so the widget is what keeps the theme colors); in RPC, JSON and print modes it falls back to a plain status entry. The glyphs follow `omp`'s `symbolPreset` (`unicode`, `nerd` or `ascii`). + +```text +telegram ● connected · @my_bot · ⤵12 ⤴11 · ⏱ 14:32 +telegram ⟳ active +2 · @my_bot · ⤵14 ⤴11 · ⏱ 14:33 +telegram ⚠ awaiting pairing · send /start to @my_bot +telegram ○ disconnected · /telegram-connect +telegram ⦸ not configured · /telegram-setup +telegram ✘ error Unable to connect. Is the computer able to access the url? +``` + +- `⤵N` counts messages received from the paired owner and `⤴N` counts messages the bridge delivered to Telegram (replies, menus and attachments; typing indicators, drafts and edits are not counted). `⏱` is the time of the latest one. Counters live in memory, start at zero when the extension loads, and are shown only while the bridge is up. +- The state word is the same ladder as before: `not configured`, `awaiting pairing`, `electing`, `disconnected`, then `active` while a turn runs or is queued, and `connected`, `leader` or `follower` otherwise. +- Messages a follower instance sends through the leader are counted by the leader process, not by the follower. +- `/telegram-status` repeats the counters as a `- messages:` line. + ### Tools available to the agent | Tool | Purpose | diff --git a/lib/status.ts b/lib/status.ts index c0f44ea..45b17e8 100644 --- a/lib/status.ts +++ b/lib/status.ts @@ -257,9 +257,16 @@ export interface TelegramStatusBarState { } export interface TelegramStatusRuntimeContext { + /** Host run mode; the colored widget is only used in the interactive TUI. */ + mode?: string; ui: { theme: TelegramStatusBarTheme; - setStatus: (key: string, text: string) => void; + setStatus(key: string, text: string | undefined): void; + setWidget?( + key: string, + content: string[] | undefined, + options?: { placement?: "aboveEditor" | "belowEditor" }, + ): void; }; } @@ -590,13 +597,19 @@ export function createTelegramStatusRuntime< const statusKey = deps.statusKey ?? "telegram"; return { updateStatus: (ctx, error) => { - ctx.ui.setStatus( - statusKey, - buildTelegramStatusBarText( - ctx.ui.theme, - deps.getStatusBarState(ctx, error), - ), + const text = buildTelegramStatusBarText( + ctx.ui.theme, + deps.getStatusBarState(ctx, error), ); + if (ctx.mode === "tui" && ctx.ui.setWidget) { + // The host strips ANSI from status text, so setStatus would drop the + // theme colors. String widgets keep them; clear the plain footer entry + // so the state is not shown twice. + ctx.ui.setWidget(statusKey, [text], { placement: "belowEditor" }); + ctx.ui.setStatus(statusKey, undefined); + return; + } + ctx.ui.setStatus(statusKey, text); }, getStatusLines: (options) => buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options), diff --git a/tests/status.test.ts b/tests/status.test.ts index 80bf5a9..683c525 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -318,6 +318,63 @@ test("Status runtime updates the status bar and exposes bridge lines", () => { ]); }); +test("Status runtime keeps theme colors in the TUI through a widget and stays plain elsewhere", () => { + const createContext = (mode: string | undefined) => { + const events: string[] = []; + return { + events, + ctx: { + mode, + ui: { + theme: statusBarTheme, + setStatus: (key: string, text: string | undefined) => { + events.push(`status:${key}:${text}`); + }, + setWidget: ( + key: string, + content: string[] | undefined, + options?: { placement?: string }, + ) => { + events.push(`widget:${key}:${options?.placement}:${content?.join("|")}`); + }, + }, + }, + }; + }; + const runtime = createTelegramStatusRuntime({ + getStatusBarState: () => ({ + hasBotToken: true, + pollingActive: true, + paired: true, + compactionInProgress: false, + processing: false, + queuedStatus: "", + }), + getBridgeStatusLineState: () => { + throw new Error("not used"); + }, + }); + + const tui = createContext("tui"); + runtime.updateStatus(tui.ctx); + assert.equal(tui.events.length, 2); + assert.match(tui.events[0], /^widget:telegram:belowEditor:telegram<\/accent> \S+ connected<\/success>$/); + assert.equal(tui.events[1], "status:telegram:undefined"); + + for (const mode of ["rpc", "json", "print", undefined]) { + const other = createContext(mode); + runtime.updateStatus(other.ctx); + assert.equal(other.events.length, 1, String(mode)); + assert.match(other.events[0], /^status:telegram:telegram<\/accent> \S+ connected<\/success>$/); + } + + const noWidgetApi = createContext("tui"); + delete (noWidgetApi.ctx.ui as { setWidget?: unknown }).setWidget; + runtime.updateStatus(noWidgetApi.ctx); + assert.equal(noWidgetApi.events.length, 1); + assert.match(noWidgetApi.events[0], /^status:telegram:/); +}); + test("Status lines expose thread reconciliation state", () => { const lines = buildTelegramBridgeStatusLines({ botUsername: "demo_bot",