pi-ui-extend 0.1.21 → 0.1.24

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.
Files changed (37) hide show
  1. package/README.md +1 -10
  2. package/bin/pix.mjs +11 -154
  3. package/dist/app/app.d.ts +1 -0
  4. package/dist/app/app.js +34 -9
  5. package/dist/app/cli/startup-info.d.ts +0 -1
  6. package/dist/app/cli/startup-info.js +0 -3
  7. package/dist/app/commands/command-session-actions.js +3 -0
  8. package/dist/app/popup/popup-menu-controller.js +7 -1
  9. package/dist/app/rendering/conversation-entry-renderer.js +29 -40
  10. package/dist/app/rendering/render-text.d.ts +6 -0
  11. package/dist/app/rendering/render-text.js +9 -0
  12. package/dist/app/rendering/tab-line-renderer.js +1 -5
  13. package/dist/app/rendering/tool-block-renderer.js +7 -1
  14. package/dist/app/screen/mouse-controller.js +14 -6
  15. package/dist/app/session/session-event-controller.js +5 -4
  16. package/dist/app/session/session-lifecycle-controller.js +0 -4
  17. package/dist/app/session/tabs-controller.d.ts +5 -1
  18. package/dist/app/session/tabs-controller.js +111 -23
  19. package/dist/app/types.d.ts +5 -0
  20. package/dist/app/workspace/workspace-actions-controller.d.ts +3 -0
  21. package/dist/app/workspace/workspace-actions-controller.js +71 -16
  22. package/dist/app/workspace/workspace-undo.js +41 -6
  23. package/dist/markdown-format.d.ts +4 -0
  24. package/dist/markdown-format.js +6 -1
  25. package/dist/theme.js +18 -18
  26. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +1 -0
  27. package/external/pi-tools-suite/src/telegram-mirror/README.md +81 -46
  28. package/external/pi-tools-suite/src/telegram-mirror/bot.ts +81 -10
  29. package/external/pi-tools-suite/src/telegram-mirror/events.ts +6 -38
  30. package/external/pi-tools-suite/src/telegram-mirror/index.ts +246 -40
  31. package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +20 -0
  32. package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +247 -17
  33. package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +75 -78
  34. package/external/pi-tools-suite/src/todo/index.ts +7 -6
  35. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +1 -1
  36. package/external/pi-tools-suite/src/web-search/index.ts +139 -2
  37. package/package.json +7 -7
@@ -20,20 +20,46 @@ The module is a no-op until you add a `telegramMirror` block to
20
20
  }
21
21
  ```
22
22
 
23
- | Field | Type | Required | Notes |
24
- |-------------|-------------------|----------|------------------------------------------------------------------------------------------------|
25
- | `enabled` | boolean | no | Defaults to `true` when the block is present and `botToken` + `chatId` are valid. |
26
- | `botToken` | string | yes | Telegram Bot API token from [@BotFather](https://t.me/BotFather). Empty string disables. |
27
- | `chatId` | number or string | yes | Numeric chat id of the private chat allowed to control the bot. Non-integer disables. |
23
+ - `enabled` (boolean, optional): defaults to `true` when the block is
24
+ present and `botToken` + `chatId` are valid.
25
+ - `botToken` (string, required): Telegram Bot API token from @BotFather.
26
+ Empty string disables the mirror.
27
+ - `chatId` (number or string, required): numeric private chat id allowed to
28
+ control the bot. Non-integer disables the mirror.
28
29
 
29
- When the block is present and valid, the module connects on the next `pi`
30
- start (or `/reload`).
30
+ When the block is present and valid, the module registers the local
31
+ `/telegram-mirror` and `/tg` slash commands. The bot does not connect until
32
+ you run one of those commands in a pi session.
33
+
34
+ ## Activation
35
+
36
+ Run this inside each pi session you want to expose to Telegram:
37
+
38
+ ```text
39
+ /telegram-mirror
40
+ ```
41
+
42
+ Short alias:
43
+
44
+ ```text
45
+ /tg
46
+ ```
47
+
48
+ Useful local variants:
49
+
50
+ - `/telegram-mirror` or `/tg`: connect this pi session to Telegram mirror.
51
+ - `/telegram-mirror status`: show local mirror role and session label.
52
+ - `/telegram-mirror stop`: stop the mirror cluster.
53
+ - `/tg-off`: stop the mirror cluster.
54
+
55
+ After activation, the leader sends a Telegram message with buttons. Use
56
+ `/menu` or `/list` in Telegram any time to reopen the project/session picker.
31
57
 
32
58
  ## How to get your chat id
33
59
 
34
60
  Open this URL in a browser (replace `<TOKEN>` with your bot token):
35
61
 
36
- ```
62
+ ```text
37
63
  https://api.telegram.org/bot<TOKEN>/getUpdates
38
64
  ```
39
65
 
@@ -60,33 +86,37 @@ so this module elects a **leader** when N pi processes share one bot:
60
86
  followers race to bind the socket; the first to win becomes the new
61
87
  leader. `activeId` resets on failover — run `/use N` again.
62
88
 
63
- No setup needed: this is fully automatic. Just run more `pi` processes.
89
+ Run `/telegram-mirror` in every `pi` process you want available in the
90
+ Telegram picker. Only one process polls Telegram; the rest register as
91
+ followers over IPC.
64
92
 
65
93
  When you start a new pi, it logs `[telegram-mirror] registered with
66
94
  leader <label>` on stderr. The leader logs `[telegram-mirror] connected
67
95
  as @<botname> (leader)`.
68
96
 
69
- ### Selecting the active instance
97
+ ### Selecting the followed project/session
70
98
 
71
- In Telegram, use `/list` and `/use`:
99
+ In Telegram, use `/menu`, `/list`, or the inline buttons:
72
100
 
73
- ```
101
+ ```text
74
102
  /list
75
103
 
76
- 1. pi-ui-extend (#12345) (leader) \[active\]
77
- 2. opencode (#67890)
104
+ 1. pi-ui-extend (#12345) (leader) [following] — idle
105
+ 2. opencode (#67890) — streaming
78
106
  3. other-repo (#99999)
79
107
 
80
- Use /use N or /use <id> to switch.
108
+ Tap a button below, or use /use N.
81
109
  ```
82
110
 
83
- ```
111
+ ```text
84
112
  /use 2
85
- → ✅ Active: opencode (#67890)
113
+ → ✅ Following: opencode (#67890)
86
114
  ```
87
115
 
88
116
  `/use` accepts a 1-based index from `/list` or a substring of the id/label.
89
- Events from non-active instances are dropped (silent).
117
+ Assistant messages are streamed only from the followed session. Status changes
118
+ from other sessions still produce Telegram signals, so you can see when a
119
+ different session starts or finishes work without switching to it.
90
120
 
91
121
  ### Cleanup
92
122
 
@@ -97,31 +127,38 @@ unlink it automatically (bind fails → connect fails → unlink → retry).
97
127
 
98
128
  ## Telegram → pix
99
129
 
100
- | Command | Effect |
101
- |-------------------|--------------------------------------------------------|
102
- | Free text | forwarded to the active pi instance as user message |
103
- | `/list` | show all known pi instances, mark active |
104
- | `/use N` `/use X` | switch active instance (by index or id/label substring)|
105
- | `/abort` `/stop` | cancel current turn on active |
106
- | `/compact` | trigger context compaction on active |
107
- | `/status` | show idle / streaming state of active |
108
- | `/say <msg>` | explicit send (escape hatch for `/`-prefixed text) |
109
- | `/disconnect` | stop the bot cluster-wide (resume with `/reload` in pi)|
110
- | `/new` | not supported via extension API run `/new` in pi |
111
- | `/help` | show command list |
130
+ - Free text: forwarded to the followed pi session as a user message.
131
+ - `/menu`: show inline project/session picker buttons.
132
+ - `/list`: show all known pi sessions and mark followed.
133
+ - `/use N` or `/use X`: follow by index, id, or label substring.
134
+ - `/abort` or `/stop`: cancel current turn on followed session.
135
+ - `/compact`: trigger context compaction on followed session.
136
+ - `/status`: show idle / streaming state of followed session.
137
+ - `/clear`: best-effort delete known bot messages from the chat.
138
+ - `/say <msg>`: explicit send, for `/`-prefixed text.
139
+ - `/disconnect`: stop the bot cluster-wide.
140
+ - `/new`: not supported via extension API; run `/new` in pi.
141
+ - `/help`: show command list.
112
142
 
113
143
  ## Pix → Telegram
114
144
 
115
145
  The leader subscribes to pix streaming events (its own + followers' via IPC)
116
- and renders one Telegram message per agent turn — but **only for the active
117
- instance**:
146
+ and renders one Telegram message per agent turn — but only assistant-visible
147
+ text from the followed session is streamed:
118
148
 
119
- - `before_agent_start` → `user: <prompt>`
120
149
  - `message_update` (`text_delta`) → appended to the active message, edited
121
150
  in place at ~1.2 s throttle (Telegram rate-limit friendly).
122
- - `tool_execution_start` → `🔧 tool: <args>` line.
123
- - `tool_execution_end` → `✅ tool: <summary>` or `❌` on error.
124
151
  - `agent_end` → final flush + `— done —` trailer.
152
+ - `agent_start` / `agent_end` from any known session → compact status signal
153
+ such as `🟡 repo (#pid) is streaming` or `🟢 repo (#pid) is idle`.
154
+
155
+ Tool calls, tool results, and thinking deltas are intentionally not mirrored
156
+ to Telegram.
157
+
158
+ Telegram does not expose a full private-chat history wipe API to bots. The
159
+ `/clear` command therefore deletes the messages the bot knows about in this
160
+ process, plus the `/clear` command message when Telegram allows it. Older
161
+ messages from previous bot runs may remain.
125
162
 
126
163
  Messages are paginated at 4096 chars (Telegram's per-message limit).
127
164
  Markdown is converted to Telegram HTML with `**bold**`, `*italic*`,
@@ -147,8 +184,8 @@ in the same config file, then `/reload` pi.
147
184
  request open. If your network blocks Telegram, you'll see repeating
148
185
  `[telegram-mirror] polling: …` errors in stderr and the bot will back
149
186
  off up to 60 s between retries.
150
- - On leader failover, the in-flight streaming output for the active turn
151
- is lost (the new leader's renderer starts empty). `activeId` also
187
+ - On leader failover, the in-flight streaming output for the followed turn
188
+ is lost (the new leader's renderer starts empty). The followed session also
152
189
  resets to the new leader; run `/use N` to switch back to a follower.
153
190
  - The cluster is single-host only (unix socket). To mirror across
154
191
  machines, use separate bot tokens.
@@ -157,12 +194,10 @@ in the same config file, then `/reload` pi.
157
194
 
158
195
  ## Files
159
196
 
160
- | File | Purpose |
161
- |-----------------|--------------------------------------------------------|
162
- | `index.ts` | module factory: role selection (leader/follower) + lifecycle |
163
- | `bot.ts` | Telegram Bot API fetch client + long-poll loop |
164
- | `ipc.ts` | unix socket JSON-lines IPC + leader election |
165
- | `multiplexer.ts`| leader-side registry + active-instance routing |
166
- | `events.ts` | pix event sink adapters + ctx capture |
167
- | `renderer.ts` | per-turn buffer, throttled edit, pagination |
168
- | `format.ts` | markdown → Telegram HTML, chunking |
197
+ - `index.ts`: module factory, activation command, role selection, lifecycle.
198
+ - `bot.ts`: Telegram Bot API fetch client and long-poll loop.
199
+ - `ipc.ts`: unix socket JSON-lines IPC and leader election.
200
+ - `multiplexer.ts`: leader-side registry and active-instance routing.
201
+ - `events.ts`: pix event to sink adapters and context capture.
202
+ - `renderer.ts`: per-turn buffer, throttled edit, pagination.
203
+ - `format.ts`: markdown to Telegram HTML and chunking.
@@ -13,15 +13,23 @@
13
13
 
14
14
  export interface TelegramUpdate {
15
15
  update_id: number;
16
- message?: {
17
- message_id: number;
18
- date: number;
19
- chat: { id: number; type: string };
16
+ message?: TelegramIncomingMessage;
17
+ callback_query?: {
18
+ id: string;
20
19
  from?: { id: number; first_name?: string; username?: string };
21
- text?: string;
20
+ message?: TelegramIncomingMessage;
21
+ data?: string;
22
22
  };
23
23
  }
24
24
 
25
+ export interface TelegramIncomingMessage {
26
+ message_id: number;
27
+ date: number;
28
+ chat: { id: number; type: string };
29
+ from?: { id: number; first_name?: string; username?: string };
30
+ text?: string;
31
+ }
32
+
25
33
  export interface TelegramMessage {
26
34
  message_id: number;
27
35
  chat: { id: number };
@@ -35,16 +43,33 @@ export interface BotConfig {
35
43
  timeoutMs?: number;
36
44
  }
37
45
 
46
+ export interface TelegramInlineKeyboardButton {
47
+ text: string;
48
+ callback_data?: string;
49
+ url?: string;
50
+ }
51
+
52
+ export interface TelegramReplyMarkup {
53
+ inline_keyboard: TelegramInlineKeyboardButton[][];
54
+ }
55
+
56
+ export interface TelegramBotCommand {
57
+ command: string;
58
+ description: string;
59
+ }
60
+
38
61
  interface SendOptions {
39
62
  parseMode?: "HTML" | "MarkdownV2" | "Markdown";
40
63
  disablePreview?: boolean;
41
64
  silent?: boolean;
42
65
  replyToMessageId?: number;
66
+ replyMarkup?: TelegramReplyMarkup;
43
67
  }
44
68
 
45
69
  interface EditOptions {
46
70
  parseMode?: "HTML" | "MarkdownV2" | "Markdown";
47
71
  disablePreview?: boolean;
72
+ replyMarkup?: TelegramReplyMarkup;
48
73
  }
49
74
 
50
75
  export class TelegramBot {
@@ -52,6 +77,7 @@ export class TelegramBot {
52
77
  private readonly allowedChatId: number;
53
78
  private readonly timeoutMs: number;
54
79
  private readonly controller = new AbortController();
80
+ private readonly sentMessageIds = new Set<number>();
55
81
  private polling = false;
56
82
  private lastUpdateId = 0;
57
83
  private consecutiveErrors = 0;
@@ -71,6 +97,14 @@ export class TelegramBot {
71
97
  return this.allowedChatId;
72
98
  }
73
99
 
100
+ get sentIds(): readonly number[] {
101
+ return [...this.sentMessageIds];
102
+ }
103
+
104
+ forgetSentId(messageId: number): void {
105
+ this.sentMessageIds.delete(messageId);
106
+ }
107
+
74
108
  isAllowedChat(chatId: number): boolean {
75
109
  return chatId === this.allowedChatId;
76
110
  }
@@ -79,26 +113,35 @@ export class TelegramBot {
79
113
  return this.requestJson("GET", "getMe", undefined);
80
114
  }
81
115
 
116
+ async setMyCommands(commands: TelegramBotCommand[]): Promise<void> {
117
+ await this.requestJson("POST", "setMyCommands", { commands });
118
+ }
119
+
82
120
  async sendMessage(text: string, options: SendOptions = {}): Promise<TelegramMessage | undefined> {
83
- return this.requestJson("POST", "sendMessage", {
121
+ const payload = await this.requestJson<{ ok: boolean; result?: TelegramMessage }>("POST", "sendMessage", {
84
122
  chat_id: this.allowedChatId,
85
123
  text,
86
124
  parse_mode: options.parseMode ?? "HTML",
87
125
  disable_web_page_preview: options.disablePreview ?? true,
88
126
  disable_notification: options.silent ?? false,
127
+ reply_markup: options.replyMarkup,
89
128
  ...(options.replyToMessageId ? { reply_to_message_id: options.replyToMessageId } : {}),
90
129
  });
130
+ if (payload.result?.message_id) this.sentMessageIds.add(payload.result.message_id);
131
+ return payload.result;
91
132
  }
92
133
 
93
134
  async editMessageText(messageId: number, text: string, options: EditOptions = {}): Promise<TelegramMessage | undefined> {
94
135
  try {
95
- return await this.requestJson("POST", "editMessageText", {
136
+ const payload = await this.requestJson<{ ok: boolean; result?: TelegramMessage }>("POST", "editMessageText", {
96
137
  chat_id: this.allowedChatId,
97
138
  message_id: messageId,
98
139
  text,
99
140
  parse_mode: options.parseMode ?? "HTML",
100
141
  disable_web_page_preview: options.disablePreview ?? true,
142
+ reply_markup: options.replyMarkup,
101
143
  });
144
+ return payload.result;
102
145
  } catch (error) {
103
146
  // Telegram returns 400 "message is not modified" if content is identical.
104
147
  // Treat that as success; surface everything else.
@@ -114,11 +157,30 @@ export class TelegramBot {
114
157
  chat_id: this.allowedChatId,
115
158
  message_id: messageId,
116
159
  });
160
+ this.sentMessageIds.delete(messageId);
117
161
  } catch {
118
162
  // best-effort
119
163
  }
120
164
  }
121
165
 
166
+ async deleteKnownMessages(extraMessageIds: readonly number[] = []): Promise<{ attempted: number; deleted: number }> {
167
+ const ids = [...new Set([...this.sentMessageIds, ...extraMessageIds])].sort((a, b) => b - a);
168
+ let deleted = 0;
169
+ for (const id of ids) {
170
+ const before = this.sentMessageIds.has(id);
171
+ await this.deleteMessage(id);
172
+ if (before && !this.sentMessageIds.has(id)) deleted += 1;
173
+ }
174
+ return { attempted: ids.length, deleted };
175
+ }
176
+
177
+ async answerCallbackQuery(callbackQueryId: string, text?: string): Promise<void> {
178
+ await this.requestJson("POST", "answerCallbackQuery", {
179
+ callback_query_id: callbackQueryId,
180
+ text,
181
+ });
182
+ }
183
+
122
184
  /**
123
185
  * Start long-polling loop. The callback receives every update from the
124
186
  * allowed chat; updates from other chats are dropped silently.
@@ -146,7 +208,7 @@ export class TelegramBot {
146
208
  }>("POST", "getUpdates", {
147
209
  offset: this.lastUpdateId > 0 ? this.lastUpdateId + 1 : undefined,
148
210
  timeout: Math.floor(this.timeoutMs / 1000),
149
- allowed_updates: ["message"],
211
+ allowed_updates: ["message", "callback_query"],
150
212
  });
151
213
 
152
214
  this.consecutiveErrors = 0;
@@ -157,8 +219,9 @@ export class TelegramBot {
157
219
 
158
220
  for (const update of payload.result) {
159
221
  if (update.update_id > this.lastUpdateId) this.lastUpdateId = update.update_id;
160
- if (!update.message) continue;
161
- if (!this.isAllowedChat(update.message.chat.id)) continue;
222
+ const chatId = getUpdateChatId(update);
223
+ if (chatId === undefined) continue;
224
+ if (!this.isAllowedChat(chatId)) continue;
162
225
  try {
163
226
  await onUpdate(update);
164
227
  } catch (handlerError) {
@@ -215,6 +278,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
215
278
  return value !== null && typeof value === "object" && !Array.isArray(value);
216
279
  }
217
280
 
281
+ function getUpdateChatId(update: TelegramUpdate): number | undefined {
282
+ // For callback_query Telegram may omit `message` or return an
283
+ // inaccessible message for older inline keyboards. In private chats the
284
+ // callback sender id is the chat id, so use it as a fallback; otherwise the
285
+ // auth gate silently drops button presses.
286
+ return update.message?.chat.id ?? update.callback_query?.message?.chat.id ?? update.callback_query?.from?.id;
287
+ }
288
+
218
289
  function removeUndefined(value: Record<string, unknown>): Record<string, unknown> {
219
290
  const out: Record<string, unknown> = {};
220
291
  for (const [k, v] of Object.entries(value)) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import type { RendererEvent } from "./renderer.js";
9
+ import type { RendererEvent, RendererInstance } from "./renderer.js";
10
10
 
11
11
  /**
12
12
  * Minimal sink for rendering events. The leader wires this to a Multiplexer
@@ -18,14 +18,8 @@ export interface RendererSink {
18
18
  }
19
19
 
20
20
  export function registerPixEventHandlers(pi: ExtensionAPI, hooks: PixMirrorHooks): void {
21
- pi.on("agent_start", () => {
22
- hooks.getRenderer()?.push({ kind: "turn_start" });
23
- });
24
-
25
- pi.on("before_agent_start", (event) => {
26
- const prompt = event?.prompt?.trim();
27
- if (!prompt) return;
28
- hooks.getRenderer()?.push({ kind: "info", text: `user: ${truncate(prompt, 200)}` });
21
+ pi.on("agent_start", (_event, ctx) => {
22
+ hooks.getRenderer()?.push({ kind: "turn_start", instance: hooks.describeInstance(ctx as ExtensionContext | undefined) });
29
23
  });
30
24
 
31
25
  pi.on("message_update", (event) => {
@@ -35,30 +29,8 @@ export function registerPixEventHandlers(pi: ExtensionAPI, hooks: PixMirrorHooks
35
29
  if (delta) hooks.getRenderer()?.push({ kind: "assistant_text", delta });
36
30
  return;
37
31
  }
38
- if (type === "thinking_delta" || type === "thinking_start") {
39
- // Render a single `💭 thinking…` marker per turn. The renderer
40
- // dedupes further thinking events so we don't spam the chat
41
- // with streaming thinking chunks.
42
- hooks.getRenderer()?.push({ kind: "thinking" });
43
- return;
44
- }
45
- });
46
-
47
- pi.on("tool_execution_start", (event) => {
48
- hooks.getRenderer()?.push({
49
- kind: "tool_start",
50
- toolCallId: event.toolCallId,
51
- toolName: event.toolName,
52
- });
53
- });
54
-
55
- pi.on("tool_execution_end", (event) => {
56
- hooks.getRenderer()?.push({
57
- kind: "tool_end",
58
- toolCallId: event.toolCallId,
59
- toolName: event.toolName,
60
- isError: event.isError,
61
- });
32
+ // Ignore thinking and toolcall events. Telegram mirrors only the
33
+ // user-visible assistant answer, not internal reasoning/tools.
62
34
  });
63
35
 
64
36
  pi.on("agent_end", () => {
@@ -69,6 +41,7 @@ export function registerPixEventHandlers(pi: ExtensionAPI, hooks: PixMirrorHooks
69
41
 
70
42
  export interface PixMirrorHooks {
71
43
  getRenderer(): RendererSink | undefined;
44
+ describeInstance(ctx: ExtensionContext | undefined): RendererInstance | undefined;
72
45
  notifyAgentEnd(): void;
73
46
  }
74
47
 
@@ -87,8 +60,3 @@ export interface ContextCapture {
87
60
  captureCompact(fn: () => void): void;
88
61
  }
89
62
 
90
- function truncate(value: string, max: number): string {
91
- const collapsed = value.replace(/\s+/g, " ").trim();
92
- if (collapsed.length <= max) return collapsed;
93
- return `${collapsed.slice(0, Math.max(0, max - 1))}…`;
94
- }