@llblab/pi-telegram 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ An outbound handler is selected by `type`. Text replies and assistant markup map
13
13
  | Source | Handler | Action |
14
14
  | ----------------- | ----------------------------- | ----------------------- |
15
15
  | Final text | `outboundHandlers[type=text]` | Transform before render |
16
- | `telegram_voice` | Voice pipeline | OGG/Opus `sendVoice` |
16
+ | `telegram_voice` | Voice pipeline | OGG/Opus `sendVoice` |
17
17
  | `telegram_button` | Built-in | Attach inline button |
18
18
 
19
19
  The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
@@ -60,24 +60,27 @@ Voice replies use one fallback pipeline:
60
60
 
61
61
  1. configured `outboundHandlers` with `type: "voice"` in `telegram.json` order
62
62
  2. programmatic `registerTelegramOutboundHandler("voice", ...)` handlers
63
- 3. registered voice synthesis providers from `@llblab/pi-telegram/lib/voice.ts`
63
+ 3. registered voice synthesis providers from `@llblab/pi-telegram/voice`
64
64
 
65
65
  This makes provider extensions a zero-config convenience without overriding explicit operator-owned `telegram.json` handlers. If several synthesis providers are registered, they are tried in registration order; the first provider that returns a valid `.ogg`/`.opus` artifact handles the reply. Returning `undefined` passes to the next provider, while thrown errors or invalid files are recorded before the next fallback is tried.
66
66
 
67
67
  ## Voice Synthesis Provider API
68
68
 
69
- Voice replies can be delivered by synthesis providers registered through `@llblab/pi-telegram/lib/voice.ts`:
69
+ Voice replies can be delivered by synthesis providers registered through `@llblab/pi-telegram/voice`:
70
70
 
71
71
  ```ts
72
- import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/lib/voice.ts";
73
-
74
- const dispose = registerTelegramVoiceSynthesisProvider(async (text, options) => {
75
- const audioPath = await synthesizeToOggOpus(text, options);
76
- return { audioPath, transcriptText: text };
77
- });
72
+ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/voice";
73
+
74
+ const dispose = registerTelegramVoiceSynthesisProvider(
75
+ async (text, options) => {
76
+ const audioPath = await synthesizeToOggOpus(text, options);
77
+ return { audioPath, transcriptText: text };
78
+ },
79
+ { id: "my-extension/tts" },
80
+ );
78
81
  ```
79
82
 
80
- Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. They own translation, TTS, speech rewriting, transcript choice, and OGG/Opus conversion. The bridge validates that the returned file ends in `.ogg` or `.opus`, sends it through Telegram `sendVoice`, and falls back to planned text if delivery fails before any visible text was delivered. Providers run after configured and programmatic voice handlers in the priority chain above.
83
+ Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. Stable registrations pass a durable `id`; omitted ids remain a compatibility path for older providers. Providers own translation, TTS, speech rewriting, transcript choice, and OGG/Opus conversion. The bridge validates that the returned file ends in `.ogg` or `.opus`, sends it through Telegram `sendVoice`, and falls back to planned text if delivery fails before any visible text was delivered. Providers run after configured and programmatic voice handlers in the priority chain above.
81
84
 
82
85
  ## Voice Markup
83
86
 
@@ -0,0 +1,266 @@
1
+ # Public API
2
+
3
+ `pi-telegram` is both a π extension and a small Telegram platform for companion extensions. This document defines the stable public surface. Everything outside this document is implementation detail unless another focused doc explicitly marks it stable.
4
+
5
+ ## Stability Levels
6
+
7
+ - **Stable:** documented here and covered by compatibility expectations.
8
+ - **Advanced stable:** public for extension authors, but lower-level; prefer higher-level APIs when possible.
9
+ - **Compatibility:** older import/config paths that remain supported but should not be used for new code.
10
+ - **Internal:** exported from source for tests or domain reuse, but not a compatibility promise.
11
+
12
+ ## Package Entrypoints
13
+
14
+ Preferred public imports:
15
+
16
+ ```ts
17
+ import telegram from "@llblab/pi-telegram";
18
+ import { registerTelegramSection } from "@llblab/pi-telegram/sections";
19
+ import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
20
+ import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
21
+ import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
22
+ import {
23
+ registerTelegramVoiceSynthesisProvider,
24
+ registerTelegramVoiceTranscriptionProvider,
25
+ } from "@llblab/pi-telegram/voice";
26
+ ```
27
+
28
+ `0.12.0` intentionally removes the published `@llblab/pi-telegram/lib/*.ts` compatibility wildcard. Integrations should use the public API domain subpaths above. Package exports point at `/api/*.ts` membranes that re-export only stable companion-extension symbols; implementation modules under `lib/` remain package-private.
29
+
30
+ ## User-Facing API
31
+
32
+ ### π commands
33
+
34
+ Stable commands inside π:
35
+
36
+ - `/telegram-setup` — configure/update the bot token.
37
+ - `/telegram-connect` — start polling in the current session and acquire ownership.
38
+ - `/telegram-disconnect` — stop polling and release ownership.
39
+ - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics.
40
+
41
+ ### Telegram commands
42
+
43
+ Stable commands inside the paired Telegram DM:
44
+
45
+ - `/start` — pair when needed and open the main application menu.
46
+ - `/compact` — open confirmation and compact when idle.
47
+ - `/next` — dispatch the next queued turn, aborting active work first when needed.
48
+ - `/continue` — enqueue a priority `continue` prompt.
49
+ - `/abort` — abort active Telegram-owned work and keep the queue.
50
+ - `/stop` — abort active Telegram-owned work and clear waiting Telegram queue items.
51
+
52
+ Hidden compatibility shortcuts may open sections directly: `/help`, `/status`, `/model`, `/thinking`, `/queue`, and `/settings`.
53
+
54
+ ### Tools and assistant-authored actions
55
+
56
+ - `telegram_attach(paths)` is the stable artifact delivery tool for generated files.
57
+ - `telegram_voice` hidden comments request Telegram-native voice delivery.
58
+ - `telegram_button` hidden comments create inline buttons whose taps enqueue prompts.
59
+
60
+ See [Outbound Handlers](./outbound.md) for exact markup forms.
61
+
62
+ ## Configuration API
63
+
64
+ Configuration lives in `~/.pi/agent/telegram.json` unless `PI_CODING_AGENT_DIR` changes the agent root.
65
+
66
+ Stable config keys:
67
+
68
+ ```ts
69
+ interface TelegramConfig {
70
+ botToken?: string;
71
+ botUsername?: string; // runtime-managed
72
+ botId?: number; // runtime-managed
73
+ allowedUserId?: number;
74
+ lastUpdateId?: number; // runtime-managed
75
+ proactivePush?: boolean;
76
+ inboundHandlers?: TelegramInboundHandlerConfig[];
77
+ attachmentHandlers?: TelegramInboundHandlerConfig[]; // compatibility alias
78
+ outboundHandlers?: TelegramOutboundHandlerConfig[];
79
+ voice?: {
80
+ replyMode?: "manual" | "mirror" | "always";
81
+ sendTranscript?: boolean;
82
+ };
83
+ time?: {
84
+ injectionMode?: "hidden" | "always" | "interval";
85
+ interval?: number;
86
+ };
87
+ }
88
+ ```
89
+
90
+ Hidden/default semantics are represented by absence:
91
+
92
+ - Voice Reply `hidden`: no `voice.replyMode` key is persisted.
93
+ - Time Injection `hidden`: no `time.injectionMode` key is persisted; if `time` becomes empty, the whole `time` object may be omitted.
94
+
95
+ Environment variables are stable only where documented in the README: bot-token bootstrap, proxy behavior, agent root, and inbound/outbound file size limits.
96
+
97
+ ## Programmatic API Matrix
98
+
99
+ High-level stable APIs:
100
+
101
+ - `registerTelegramSection()`
102
+ - Identity: required `id`.
103
+ - Purpose: managed menu/settings UI surfaces.
104
+ - `registerTelegramVoiceTranscriptionProvider()`
105
+ - Identity: required stable `id` for new code.
106
+ - Purpose: STT fallback for voice/audio input.
107
+ - `registerTelegramVoiceSynthesisProvider()`
108
+ - Identity: required stable `id` for new code.
109
+ - Purpose: TTS fallback for Telegram voice replies.
110
+
111
+ Low-level stable buses:
112
+
113
+ - `registerTelegramUpdateHandler()`
114
+ - Identity: no id.
115
+ - Purpose: observe or consume raw Telegram updates before default routing.
116
+ - `registerTelegramInboundHandler()`
117
+ - Identity: no id.
118
+ - Purpose: generic Telegram-to-π transforms.
119
+ - `registerTelegramOutboundHandler()`
120
+ - Identity: no id.
121
+ - Purpose: generic final-reply transforms or voice command fallbacks.
122
+
123
+ Advanced stable diagnostics:
124
+
125
+ - `recordTelegramRuntimeEvent()`
126
+ - Identity: caller supplies category.
127
+ - Purpose: surface companion diagnostics in `/telegram-status`.
128
+
129
+ All registration APIs return a disposer. Companion extensions should call disposers on shutdown and re-register on session start when they recreate runtime state. Low-level bus APIs intentionally avoid ids and run in registration order. High-level provider/UI APIs require stable identity in their public contract so diagnostics, replacement, and cleanup are understandable. Generated voice-provider ids remain a temporary compatibility path where documented.
130
+
131
+ ## Sections
132
+
133
+ Import from `@llblab/pi-telegram/sections`.
134
+
135
+ ```ts
136
+ const unregister = registerTelegramSection({
137
+ id: "@scope/my-extension",
138
+ label: "🧩 My extension",
139
+ order: 10,
140
+ render: async (ctx) => ({
141
+ text: "<b>My extension</b>",
142
+ parseMode: "html",
143
+ replyMarkup: {
144
+ inline_keyboard: [
145
+ [{ text: "▶️ Run", callback_data: ctx.callbackData("run") }],
146
+ ],
147
+ },
148
+ }),
149
+ handleCallback: async (ctx) => {
150
+ if (ctx.action !== "run") return "pass";
151
+ await ctx.enqueuePrompt("Run my extension workflow.");
152
+ await ctx.answerCallback("Queued");
153
+ return "handled";
154
+ },
155
+ });
156
+ ```
157
+
158
+ Contract:
159
+
160
+ - `id` is unique per active registry. Duplicate ids are rejected.
161
+ - `ctx.callbackData(action, payload?)` builds compact `section:` callbacks and validates Telegram's 64-byte limit.
162
+ - `ctx.edit()` and `ctx.open()` auto-prepend the correct Back/Main-menu row.
163
+ - Section errors are isolated and surfaced as callback popups/diagnostics.
164
+
165
+ Full behavior: [Extension Sections](./sections.md).
166
+
167
+ ## Updates
168
+
169
+ Import from `@llblab/pi-telegram/updates`.
170
+
171
+ ```ts
172
+ const off = registerTelegramUpdateHandler(async (update) => {
173
+ const data = (update as { callback_query?: { data?: string } }).callback_query
174
+ ?.data;
175
+ if (!data?.startsWith("myext:")) return "pass";
176
+ await handleMyCallback(data);
177
+ return "consume";
178
+ });
179
+ ```
180
+
181
+ Use this as a low-level escape hatch. Prefer sections for menu-integrated UI.
182
+
183
+ Full behavior: [Updates](./updates.md).
184
+
185
+ ## Inbound
186
+
187
+ Import from `@llblab/pi-telegram/inbound`.
188
+
189
+ ```ts
190
+ const off = registerTelegramInboundHandler("document", async ({ file }) => {
191
+ if (!file?.mimeType?.includes("pdf")) return undefined;
192
+ return await extractPdfText(file.path);
193
+ });
194
+ ```
195
+
196
+ Priority order:
197
+
198
+ 1. configured `inboundHandlers`
199
+ 2. compatibility `attachmentHandlers`
200
+ 3. programmatic inbound handlers
201
+ 4. voice transcription providers
202
+ 5. built-in text-file fallback
203
+
204
+ Full behavior: [Inbound Handlers](./inbound.md).
205
+
206
+ ## Outbound
207
+
208
+ Import from `@llblab/pi-telegram/outbound`.
209
+
210
+ ```ts
211
+ const off = registerTelegramOutboundHandler("text", async (text) => {
212
+ return await rewriteFinalText(text);
213
+ });
214
+ ```
215
+
216
+ Programmatic outbound handlers are fallbacks/transformers behind operator-owned `telegram.json` configuration. Voice delivery priority is configured voice handlers, then programmatic `voice` handlers, then synthesis providers.
217
+
218
+ Full behavior: [Outbound Handlers](./outbound.md).
219
+
220
+ ## Voice Providers
221
+
222
+ Import from `@llblab/pi-telegram/voice`.
223
+
224
+ ```ts
225
+ const offStt = registerTelegramVoiceTranscriptionProvider(
226
+ async (file) => {
227
+ if (file.kind !== "voice" && file.kind !== "audio") return undefined;
228
+ return { text: await transcribe(file.path) };
229
+ },
230
+ { id: "@scope/my-extension/stt" },
231
+ );
232
+
233
+ const offTts = registerTelegramVoiceSynthesisProvider(
234
+ async (text, options) => {
235
+ const audioPath = await synthesizeOggOpus(text, options);
236
+ return { audioPath, transcriptText: text };
237
+ },
238
+ { id: "@scope/my-extension/tts" },
239
+ );
240
+ ```
241
+
242
+ Stable voice-provider registrations pass a durable `id`. Omitting `id` is a compatibility path for older providers and receives a generated session-local id. Providers return `undefined` to pass. TTS providers must return `.ogg` or `.opus` files for native Telegram voice notes.
243
+
244
+ Full behavior: [Voice Integration](./voice.md).
245
+
246
+ ## Callback Namespaces
247
+
248
+ Owned prefixes are reserved by `pi-telegram`: `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`.
249
+
250
+ Companion extensions should use their own short prefix for raw callbacks or use `ctx.callbackData()` inside sections. Unknown unowned callbacks may be forwarded to π as `[callback] <data>` after built-in handlers decline them.
251
+
252
+ Full behavior: [Callback Namespaces](./callback-namespaces.md).
253
+
254
+ ## Internal Surface
255
+
256
+ The following are not stable public contracts unless explicitly documented elsewhere:
257
+
258
+ - queue/runtime/lifecycle stores and planners
259
+ - menu implementation helpers
260
+ - polling/lock internals
261
+ - Telegram API transport helpers
262
+ - rendering internals
263
+ - command implementation helpers
264
+ - test support functions
265
+
266
+ They are intentionally not exposed through a `./lib/*.ts` export wildcard in `0.12.0`.
@@ -10,7 +10,7 @@
10
10
 
11
11
  Telegram Extension Sections let ordinary pi extensions add structured UI surfaces to the `pi-telegram` inline application menu. The platform mirrors π's own extensibility model: small, composable extensions that plug into a shared shell without owning transport, polling, authorization, or menu lifecycle.
12
12
 
13
- `pi-telegram` stays the single bot operator. Extensions register typed sections; the bridge handles rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. No second poller, no new loader — just one `registerTelegramSection()` call.
13
+ `pi-telegram` stays the single bot operator. Extensions register typed sections; the bridge handles rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. No second polling loop, no new loader — just one `registerTelegramSection()` call.
14
14
 
15
15
  ## 2. Contract Layers
16
16
 
@@ -39,7 +39,7 @@ The `id` is the owner identity. No separate `owner` field. Used for registry own
39
39
  ## 4. Registration Shape
40
40
 
41
41
  ```ts
42
- import { registerTelegramSection } from "@llblab/pi-telegram/lib/extension-sections.ts";
42
+ import { registerTelegramSection } from "@llblab/pi-telegram/sections";
43
43
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
44
44
 
45
45
  export default function (pi: ExtensionAPI) {
@@ -127,12 +127,12 @@ unregister(); // removes from main menu, settings, and callback routing
127
127
 
128
128
  Two paths, same registry:
129
129
 
130
- **Typed import (preferred):** Extension imports `registerTelegramSection` from `@llblab/pi-telegram/lib/extension-sections.ts`. The function reads from a `globalThis` registry set by `pi-telegram` at startup.
130
+ **Typed import (preferred):** Extension imports `registerTelegramSection` from `@llblab/pi-telegram/sections`. The function reads from a `globalThis` registry set by `pi-telegram` at startup. In `0.12.0`, package-private `@llblab/pi-telegram/lib/*.ts` deep imports are no longer exported.
131
131
 
132
- **Relative import (local):** When the extension cannot resolve `@llblab/pi-telegram` as an npm package, use a relative path:
132
+ **Relative import (local):** When the extension cannot resolve `@llblab/pi-telegram` as an npm package, use the public API membrane via a relative path:
133
133
 
134
134
  ```ts
135
- import { registerTelegramSection } from "../pi-telegram/lib/extension-sections.ts";
135
+ import { registerTelegramSection } from "../pi-telegram/api/sections.ts";
136
136
  ```
137
137
 
138
138
  **GlobalThis bridge (zero-coupling):** `pi-telegram` exposes `__piTelegramSectionRegistry__` on `globalThis`. The typed import is a thin wrapper. Extensions never touch the raw registry.
@@ -187,18 +187,19 @@ section:<token>:<action>:<payload>
187
187
 
188
188
  Example: `section:0:counter:5`
189
189
 
190
- The token is an implementation detail. Section authors **never** write `section:` strings manually. Use `ctx.callbackData(action, payload?)` which fills in the correct token.
190
+ The token is an implementation detail. Section authors **never** write `section:` strings manually. Use `ctx.callbackData(action, payload?)` which fills in the correct token and rejects callback data above Telegram's 64-byte limit.
191
191
 
192
192
  ### Routing order
193
193
 
194
- 1. Telegram update arrives through the single `pi-telegram` poller
195
- 2. External handlers observe/consume (raw update interception)
194
+ 1. Telegram update arrives through the single `pi-telegram` polling loop
195
+ 2. Update handlers observe/consume (raw update interception)
196
196
  3. Button action store (`tgbtn:*`)
197
- 4. Queue menu callbacks (`queue:*`)
198
- 5. Settings menu callbacks (`settings:*`)
199
- 6. Built-in menu callbacks (`menu:*`, `model:*`, `thinking:*`, `status:*`)
200
- 7. Section callbacks (`section:*`) dispatched before step 6's full handler
201
- 8. Unknown callbacks fall back to `[callback]` prompt text
197
+ 4. Compact confirmation callbacks (`compact:*`)
198
+ 5. Queue menu callbacks (`queue:*`)
199
+ 6. Settings menu callbacks (`settings:*`)
200
+ 7. Built-in menu callbacks (`menu:*`, `model:*`, `thinking:*`, `status:*`)
201
+ 8. Section callbacks (`section:*`) dispatched before step 7's full handler
202
+ 9. Unknown callbacks fall back to `[callback]` prompt text
202
203
 
203
204
  ### Handler return values
204
205
 
@@ -221,7 +222,7 @@ If a section is unregistered or a token is unknown, the callback is answered wit
221
222
 
222
223
  > "This section is no longer available."
223
224
 
224
- Section errors are caught and surfaced as popup text. No unhandled exceptions leak to the poller.
225
+ Section errors are caught and surfaced as popup text. No unhandled exceptions leak to polling.
225
226
 
226
227
  ## 8. Navigation Hierarchy
227
228
 
@@ -298,7 +299,7 @@ Context ports are intentionally narrow. Sections **cannot**:
298
299
 
299
300
  - Read/write filesystem
300
301
  - Access raw process or bot clients
301
- - Start a second poller
302
+ - Start a second polling loop
302
303
  - Mutate session state
303
304
  - Send arbitrary Telegram API calls
304
305
 
@@ -320,12 +321,15 @@ handleCallback: async (ctx) => {
320
321
  text: `<b>Delete ${ctx.payload}?</b>\n\nThis cannot be undone.`,
321
322
  parseMode: "html",
322
323
  replyMarkup: {
323
- inline_keyboard: [[
324
- { text: "✅ Yes, delete",
325
- callback_data: ctx.callbackData("confirm-delete", ctx.payload) },
326
- { text: " Cancel",
327
- callback_data: ctx.callbackData("cancel") },
328
- ]],
324
+ inline_keyboard: [
325
+ [
326
+ {
327
+ text: " Yes, delete",
328
+ callback_data: ctx.callbackData("confirm-delete", ctx.payload),
329
+ },
330
+ { text: "❌ Cancel", callback_data: ctx.callbackData("cancel") },
331
+ ],
332
+ ],
329
333
  },
330
334
  });
331
335
  return "handled";
@@ -339,7 +343,7 @@ handleCallback: async (ctx) => {
339
343
  await ctx.deleteMessage();
340
344
  return "handled";
341
345
  }
342
- }
346
+ };
343
347
  ```
344
348
 
345
349
  `ctx.deleteMessage()` removes the dialog from chat after the user makes a choice. Callbacks from chat buttons route through the same `handleCallback` — the same `ctx.callbackData()` works regardless of where the button lives. The extension owns its callback namespace; the bridge owns transport.
@@ -356,7 +360,7 @@ section:0:settings:open → open settings root
356
360
  section:0:<action>:<payload> → forwarded to handleCallback
357
361
  ```
358
362
 
359
- `section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
363
+ `section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `compact:`, `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
360
364
 
361
365
  ### Inline keyboard layout
362
366
 
@@ -378,7 +382,7 @@ The platform inherits from π's own extension model:
378
382
 
379
383
  - `export default function(pi)` → `registerTelegramSection(section)`
380
384
  - `pi.on("shutdown", ...)` → disposer from `registerTelegramSection`
381
- - Typed imports → typed import from `@llblab/pi-telegram/lib/extension-sections.ts`
385
+ - Typed imports → typed import from `@llblab/pi-telegram/sections`
382
386
  - `globalThis` registry → `__piTelegramSectionRegistry__` on `globalThis`
383
387
  - Identity from `package.json/name` → same identity rules as Locks Standard
384
388
  - Narrow typed context ports → `TelegramSectionContext` / `TelegramSectionCallbackContext`
@@ -419,7 +423,7 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Section runtim
419
423
 
420
424
  ### Non-goals:
421
425
 
422
- - No second Telegram poller
426
+ - No second Telegram polling loop
423
427
  - No new pi extension loader
424
428
  - No generic webview system
425
429
  - No default filesystem mutation API
@@ -429,8 +433,8 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Section runtim
429
433
  ## 14. Relationship to Other Standards
430
434
 
431
435
  - [Callback Namespaces](./callback-namespaces.md): defines `section:` as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
432
- - [External Handlers](./external-handlers.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
433
- - [Extension Locks](../docs/locks.md) (external): same identity key rules (`package.json/name` → canonical id)
436
+ - [Updates](./updates.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
437
+ - [Extension Locks](../docs/locks.md) (polling): same identity key rules (`package.json/name` → canonical id)
434
438
  - [Command Templates](./command-templates.md): sections do not execute command templates by default. UI registration + callback routing, not shell execution
435
439
 
436
440
  ## 15. Demo Extension
@@ -0,0 +1,165 @@
1
+ # UI Style Guide
2
+
3
+ Small standard for inline buttons, menu rows, state controls, cards, and confirmation dialogs.
4
+
5
+ ## Principles
6
+
7
+ - Keep UI compact and phone-readable.
8
+ - Put emoji where they help scanning, not everywhere.
9
+ - Use one strong indicator for current selection; avoid emoji noise on every option.
10
+ - Match label casing to control role.
11
+ - Prefer minimal, clear configuration UI over exhaustive explanation.
12
+ - Preserve domain-owned callback prefixes and behavior in the owning module.
13
+
14
+ ## Action Buttons
15
+
16
+ Action buttons perform an operation.
17
+
18
+ Rules:
19
+
20
+ - Use an emoji plus capitalized action text.
21
+ - Prefer direct verb or action noun.
22
+ - Keep labels short.
23
+
24
+ Examples:
25
+
26
+ - `🗜 Yes, compact`
27
+ - `❌ No`
28
+ - `🗑 Yes, delete`
29
+ - `☑️ Activate`
30
+
31
+ ## State & Navigation Buttons
32
+
33
+ State buttons show the current state and navigate to a submenu or detail rather than performing an operation directly.
34
+
35
+ Rules:
36
+
37
+ - Use an emoji that reflects the current state.
38
+ - Use Capitalized, descriptive state text.
39
+ - Tapping opens a submenu or returns to the parent list.
40
+
41
+ Examples:
42
+
43
+ - `🟢 Active` — model detail, navigates back to model list
44
+ - `📌 Proactive push: On` — settings row, opens the toggle submenu
45
+ - `👄 Voice reply: Mirror` — settings row, opens the option list
46
+
47
+ ## Boolean Toggles
48
+
49
+ Boolean settings use a horizontal `On` / `Off` pair, like a checkbox stretched across two buttons.
50
+
51
+ Rules:
52
+
53
+ - Keep the pair in one row: `On` left, `Off` right.
54
+ - Use Capitalized labels.
55
+ - Always show an indicator on both buttons to avoid horizontal label shift.
56
+ - Mark active `On` with `🟢`.
57
+ - Mark active `Off` with `🟡`.
58
+ - Mark the inactive value with `⚫️`.
59
+
60
+ Examples:
61
+
62
+ - `🟢 On` / `⚫️ Off`
63
+ - `⚫️ On` / `🟡 Off`
64
+
65
+ ## Horizontal Tabs
66
+
67
+ Tabs or small mutually-exclusive scopes use a horizontal row.
68
+
69
+ Rules:
70
+
71
+ - Use Capitalized labels.
72
+ - Always show an indicator on every tab to avoid horizontal label shift.
73
+ - Use active tab color to convey semantics:
74
+ - `🟣` for the default / normal state (All models, Normal priority).
75
+ - `🟡` for an elevated or filtered state (Scoped models, Priority).
76
+ - `🟣` for neutral navigation controls (page picker).
77
+ - Mark inactive tabs with `⚫️`.
78
+
79
+ Examples:
80
+
81
+ - `🟡 Scoped` / `⚫️ All`
82
+ - `⚫️ Priority` / `🟣 Normal`
83
+ - `1` / `🟣 2` / `3`
84
+
85
+ ## Vertical Option Lists
86
+
87
+ Vertical option lists choose one value from a potentially longer list, for example model selection, thinking level, voice reply mode, or time injection mode.
88
+
89
+ Rules:
90
+
91
+ - Put each option on its own row.
92
+ - Mark only the current value with `🟢`.
93
+ - Leave non-current values without emoji.
94
+ - Use lowercase labels when the option is a value.
95
+
96
+ Examples:
97
+
98
+ - `🟢 mirror`
99
+ - `manual`
100
+ - `always`
101
+
102
+ ## Navigation
103
+
104
+ Inline submenu navigation is hierarchical.
105
+
106
+ Rules:
107
+
108
+ - Put the navigation row first.
109
+ - First-level submenus opened from the main inline menu start with `⬆️ Main menu`.
110
+ - Deeper submenus start with `⬆️ Back`.
111
+ - `Main menu` returns to the root inline menu.
112
+ - `Back` returns one level up, never directly to the root unless the parent is the root.
113
+
114
+ Examples:
115
+
116
+ - Main menu → Settings: first row is `⬆️ Main menu`.
117
+ - Settings → Voice reply mode: first row is `⬆️ Back`.
118
+
119
+ ## Message Cards
120
+
121
+ Message cards sent by the bot should start with a strong heading.
122
+
123
+ Rules:
124
+
125
+ - Start with a bold heading or, for dialogs, a bold question.
126
+ - Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
127
+ - Explain what the setting does and what the options mean only as much as needed.
128
+ - Keep descriptions short and clear.
129
+
130
+ Examples:
131
+
132
+ ```html
133
+ <b>👄 Voice reply mode:</b> <code>mirror</code>
134
+ ```
135
+
136
+ ```html
137
+ <b>Queue</b>
138
+ ```
139
+
140
+ ## Confirmation Dialogs
141
+
142
+ Confirmation dialogs protect risky or disruptive actions.
143
+
144
+ Rules:
145
+
146
+ - Body text is one bold text-only question.
147
+ - Do not put emoji in the dialog question.
148
+ - Do not add explanatory body copy unless the risk cannot be understood from the question and action labels.
149
+ - Put emoji on the buttons, not in the question.
150
+ - Preserve dialog-specific button order by intent.
151
+
152
+ Example:
153
+
154
+ ```html
155
+ <b>Compact session?</b>
156
+ ```
157
+
158
+ Buttons:
159
+
160
+ - `🗜 Yes, compact`
161
+ - `❌ No`
162
+
163
+ ## Callback Ownership
164
+
165
+ UI style does not change callback ownership. Callback prefixes remain owned by their feature domain and must be listed in callback namespace documentation when they become public collision risks.