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
@@ -44,7 +44,9 @@ import {
44
44
  IpcSocket,
45
45
  buildInstanceId,
46
46
  tryAcquireLeadership,
47
+ updateInstanceInfo,
47
48
  type IpcMessage,
49
+ type InstanceInfo,
48
50
  } from "./ipc.js";
49
51
  import { Multiplexer, type LocalDispatch } from "./multiplexer.js";
50
52
 
@@ -55,19 +57,34 @@ interface MirrorContext {
55
57
  isIdle(): boolean;
56
58
  hasPendingMessages(): boolean;
57
59
  compact(): void;
60
+ currentDialog(): string | undefined;
61
+ }
62
+
63
+ interface SessionSnapshot {
64
+ cwd: string;
65
+ sessionId?: string;
66
+ sessionFile?: string;
67
+ sessionName?: string;
58
68
  }
59
69
 
60
70
  const RECONNECT_DELAY_MS = 2_000;
71
+ const COMMAND_NAME = "telegram-mirror";
72
+ const COMMAND_ALIAS = "tg";
73
+ const COMMAND_OFF = "tg-off";
61
74
 
62
75
  export default function telegramMirror(pi: ExtensionAPI): void {
63
76
  const cfg = loadTelegramMirrorConfig();
64
- if (!cfg || !cfg.enabled) {
65
- // Soft-disable: no logging at startup, the user simply hasn't opted in.
77
+ if (!cfg) {
78
+ // Soft-disable: no logging at startup, the user hasn't configured
79
+ // botToken/chatId yet. When the block exists, even with
80
+ // enabled:false, still register /telegram-mirror and /tg so the
81
+ // user can activate the bot explicitly by slash command.
66
82
  return;
67
83
  }
68
84
 
69
85
  const { botToken: token, chatId } = cfg;
70
- const { id: selfId, info: selfInfo } = buildInstanceId();
86
+ const { id: selfId, info: baseSelfInfo } = buildInstanceId();
87
+ let selfInfo: InstanceInfo = baseSelfInfo;
71
88
 
72
89
  let role: Role = "starting";
73
90
  let bot: TelegramBot | undefined;
@@ -80,6 +97,7 @@ export default function telegramMirror(pi: ExtensionAPI): void {
80
97
  let reconnectTimer: NodeJS.Timeout | undefined;
81
98
  let shutdown = false;
82
99
  let disabled = false;
100
+ let activationRequested = false;
83
101
 
84
102
  const log = (message: string): void => {
85
103
  // eslint-disable-next-line no-console
@@ -87,13 +105,16 @@ export default function telegramMirror(pi: ExtensionAPI): void {
87
105
  };
88
106
 
89
107
  // Dispatch the leader uses to execute commands on its own pi session.
90
- const localDispatch: LocalDispatch = {
91
- sendUserMessage(text) {
92
- pi.sendUserMessage(text);
93
- },
94
- abort() {
95
- mirrorCtx?.abort();
96
- },
108
+ const localDispatch: LocalDispatch = {
109
+ sendUserMessage(text) {
110
+ pi.sendUserMessage(text);
111
+ },
112
+ currentDialog() {
113
+ return mirrorCtx?.currentDialog();
114
+ },
115
+ abort() {
116
+ mirrorCtx?.abort();
117
+ },
97
118
  compact() {
98
119
  mirrorCtx?.compact();
99
120
  },
@@ -118,12 +139,18 @@ export default function telegramMirror(pi: ExtensionAPI): void {
118
139
 
119
140
  const hooks: PixMirrorHooks = {
120
141
  getRenderer: () => eventSink,
142
+ describeInstance: (ctx) => describeInstance(ctx),
121
143
  notifyAgentEnd: () => undefined,
122
144
  };
123
145
 
124
146
  registerPixEventHandlers(pi, hooks);
147
+ registerActivationCommand(COMMAND_NAME);
148
+ registerActivationCommand(COMMAND_ALIAS);
149
+ registerOffCommand();
125
150
 
126
151
  pi.on("session_start", async (_event, ctx) => {
152
+ refreshCtx(ctx as ExtensionContext | undefined);
153
+ refreshSelfInfo(ctx as ExtensionContext | undefined);
127
154
  if (!captureHooksInstalled) {
128
155
  captureHooksInstalled = true;
129
156
  captureAbortableContext(ctx as ExtensionContext | undefined, {
@@ -145,11 +172,17 @@ export default function telegramMirror(pi: ExtensionAPI): void {
145
172
  },
146
173
  });
147
174
  }
148
- await start();
175
+ if (activationRequested) await start();
149
176
  });
150
177
 
151
- pi.on("agent_start", (_e, ctx) => refreshCtx(ctx as ExtensionContext | undefined));
152
- pi.on("before_agent_start", (_e, ctx) => refreshCtx(ctx as ExtensionContext | undefined));
178
+ pi.on("agent_start", (_e, ctx) => {
179
+ refreshCtx(ctx as ExtensionContext | undefined);
180
+ refreshSelfInfo(ctx as ExtensionContext | undefined);
181
+ });
182
+ pi.on("before_agent_start", (_e, ctx) => {
183
+ refreshCtx(ctx as ExtensionContext | undefined);
184
+ refreshSelfInfo(ctx as ExtensionContext | undefined);
185
+ });
153
186
 
154
187
  pi.on("session_shutdown", async (event) => {
155
188
  // On reload/fork the module will be reloaded in the same process —
@@ -160,6 +193,7 @@ export default function telegramMirror(pi: ExtensionAPI): void {
160
193
  });
161
194
 
162
195
  async function start(): Promise<void> {
196
+ activationRequested = true;
163
197
  if (shutdown) return;
164
198
  if (disabled) return;
165
199
  if (role !== "starting") return;
@@ -211,6 +245,17 @@ export default function telegramMirror(pi: ExtensionAPI): void {
211
245
  await stepDown();
212
246
  return;
213
247
  }
248
+ await created.setMyCommands([
249
+ { command: "menu", description: "Choose project/session" },
250
+ { command: "list", description: "List pi sessions" },
251
+ { command: "use", description: "Follow a session by number" },
252
+ { command: "status", description: "Show followed session status" },
253
+ { command: "clear", description: "Clear known bot messages" },
254
+ { command: "abort", description: "Abort followed session" },
255
+ { command: "compact", description: "Compact followed session" },
256
+ { command: "disconnect", description: "Stop Telegram mirror" },
257
+ { command: "help", description: "Show help" },
258
+ ]).catch((error) => log(`setMyCommands failed: ${errorMessage(error)}`));
214
259
  bot = created;
215
260
  log(`connected as @${me.result.username} (leader) [${selfInfo.label}]`);
216
261
  } catch (error) {
@@ -231,15 +276,16 @@ export default function telegramMirror(pi: ExtensionAPI): void {
231
276
  standDown: clusterStandDown,
232
277
  log,
233
278
  });
234
- multiplexer.init();
279
+ multiplexer.init();
235
280
 
236
- bot.startPolling(async (update) => {
237
- const text = update.message?.text;
238
- if (typeof text !== "string") return;
239
- if (!update.message?.chat || !bot?.isAllowedChat(update.message.chat.id)) return;
240
- await multiplexer?.handleTgText(text);
241
- });
242
- }
281
+ bot.startPolling(async (update) => {
282
+ await multiplexer?.handleTelegramUpdate(update);
283
+ });
284
+ await bot.sendMessage(`✅ Telegram mirror active: ${selfInfo.label}`, {
285
+ replyMarkup: { inline_keyboard: [[{ text: "🧭 Choose project/session", callback_data: "tg:list" }]] },
286
+ }).catch((error) => log(`startup message failed: ${errorMessage(error)}`));
287
+ await multiplexer.showActiveDialog();
288
+ }
243
289
 
244
290
  function becomeFollower(socket: IpcSocket): void {
245
291
  role = "follower";
@@ -309,15 +355,53 @@ export default function telegramMirror(pi: ExtensionAPI): void {
309
355
  socket.send({ type: "command_ack", reqId, ok, error });
310
356
  }
311
357
 
312
- async function handleFollowerQuery(socket: IpcSocket, reqId: string, query: string): Promise<void> {
313
- if (query === "status") {
314
- const result = mirrorCtx
315
- ? { idle: mirrorCtx.isIdle(), hasPending: mirrorCtx.hasPendingMessages() }
316
- : { idle: true, hasPending: false };
317
- socket.send({ type: "query_reply", reqId, ok: true, result });
318
- return;
358
+ async function handleFollowerQuery(socket: IpcSocket, reqId: string, query: string): Promise<void> {
359
+ if (query === "status") {
360
+ const result = mirrorCtx
361
+ ? { idle: mirrorCtx.isIdle(), hasPending: mirrorCtx.hasPendingMessages() }
362
+ : { idle: true, hasPending: false };
363
+ socket.send({ type: "query_reply", reqId, ok: true, result });
364
+ return;
365
+ }
366
+ if (query === "dialog") {
367
+ socket.send({ type: "query_reply", reqId, ok: true, result: { text: mirrorCtx?.currentDialog() ?? "" } });
368
+ return;
369
+ }
370
+ socket.send({ type: "query_reply", reqId, ok: false, error: `unknown query: ${query}` });
319
371
  }
320
- socket.send({ type: "query_reply", reqId, ok: false, error: `unknown query: ${query}` });
372
+
373
+ function registerActivationCommand(name: string): void {
374
+ pi.registerCommand(name, {
375
+ description: name === COMMAND_NAME ? "Start Telegram mirror and show connection status" : "Alias for /telegram-mirror",
376
+ handler: async (args, ctx) => {
377
+ refreshCtx(ctx as ExtensionContext | undefined);
378
+ refreshSelfInfo(ctx as ExtensionContext | undefined);
379
+ const trimmed = args.trim();
380
+ if (trimmed === "status") {
381
+ notify(ctx, localStatusText(), "info");
382
+ return;
383
+ }
384
+ if (trimmed === "stop" || trimmed === "disconnect") {
385
+ await clusterStandDown();
386
+ notify(ctx, "Telegram mirror stopped. Run /telegram-mirror to start again.", "info");
387
+ return;
388
+ }
389
+ disabled = false;
390
+ activationRequested = true;
391
+ await start();
392
+ notify(ctx, localStatusText(), "info");
393
+ },
394
+ });
395
+ }
396
+
397
+ function registerOffCommand(): void {
398
+ pi.registerCommand(COMMAND_OFF, {
399
+ description: "Stop Telegram mirror cluster",
400
+ handler: async (_args, ctx) => {
401
+ await clusterStandDown();
402
+ notify(ctx, "Telegram mirror stopped. Run /tg or /telegram-mirror to start again.", "info");
403
+ },
404
+ });
321
405
  }
322
406
 
323
407
  async function stepDown(): Promise<void> {
@@ -351,6 +435,7 @@ export default function telegramMirror(pi: ExtensionAPI): void {
351
435
  async function clusterStandDown(): Promise<void> {
352
436
  if (disabled) return;
353
437
  disabled = true;
438
+ activationRequested = false;
354
439
  if (server && role === "leader") {
355
440
  try {
356
441
  server.broadcast({ type: "stand_down" });
@@ -392,19 +477,46 @@ export default function telegramMirror(pi: ExtensionAPI): void {
392
477
  function refreshCtx(ctx: ExtensionContext | undefined): void {
393
478
  if (!ctx) return;
394
479
  if (!mirrorCtx) {
395
- mirrorCtx = makeCtx({
396
- abort: () => ctx.abort(),
397
- isIdle: () => ctx.isIdle(),
398
- hasPendingMessages: () => ctx.hasPendingMessages(),
399
- compact: () => ctx.compact(),
400
- });
401
- return;
402
- }
480
+ mirrorCtx = makeCtx({
481
+ abort: () => ctx.abort(),
482
+ isIdle: () => ctx.isIdle(),
483
+ hasPendingMessages: () => ctx.hasPendingMessages(),
484
+ compact: () => ctx.compact(),
485
+ currentDialog: () => currentDialogFromContext(ctx),
486
+ });
487
+ return;
488
+ }
403
489
  const m = mirrorCtx as Mutable<MirrorContext>;
404
490
  m.abort = () => ctx.abort();
405
- m.isIdle = () => ctx.isIdle();
406
- m.hasPendingMessages = () => ctx.hasPendingMessages();
407
- m.compact = () => ctx.compact();
491
+ m.isIdle = () => ctx.isIdle();
492
+ m.hasPendingMessages = () => ctx.hasPendingMessages();
493
+ m.compact = () => ctx.compact();
494
+ m.currentDialog = () => currentDialogFromContext(ctx);
495
+ }
496
+
497
+ function refreshSelfInfo(ctx: ExtensionContext | undefined): void {
498
+ const snapshot = sessionSnapshot(ctx);
499
+ if (!snapshot) return;
500
+ selfInfo = updateInstanceInfo(selfInfo, snapshot);
501
+ multiplexer?.updateSelfInfo(selfInfo);
502
+ if (role === "follower" && clientSocket && !clientSocket.isClosed) {
503
+ clientSocket.send({ type: "instance_update", info: selfInfo });
504
+ }
505
+ }
506
+
507
+ function describeInstance(ctx: ExtensionContext | undefined) {
508
+ refreshSelfInfo(ctx);
509
+ return {
510
+ label: selfInfo.label,
511
+ cwd: selfInfo.cwd,
512
+ ...(selfInfo.sessionId ? { sessionId: selfInfo.sessionId } : {}),
513
+ ...(selfInfo.sessionName ? { sessionName: selfInfo.sessionName } : {}),
514
+ };
515
+ }
516
+
517
+ function localStatusText(): string {
518
+ const status = role === "leader" ? "leader/polling" : role === "follower" ? "follower/connected" : "not connected";
519
+ return `Telegram mirror: ${status}\n${selfInfo.label}${selfInfo.sessionName ? ` · ${selfInfo.sessionName}` : ""}`;
408
520
  }
409
521
  }
410
522
 
@@ -416,9 +528,103 @@ function makeCtx(seed: Partial<MirrorContext>): MirrorContext {
416
528
  isIdle: seed.isIdle ?? (() => true),
417
529
  hasPendingMessages: seed.hasPendingMessages ?? (() => false),
418
530
  compact: seed.compact ?? (() => undefined),
531
+ currentDialog: seed.currentDialog ?? (() => undefined),
419
532
  };
420
533
  }
421
534
 
535
+ const TRANSCRIPT_MAX_MESSAGES = 40;
536
+ const TRANSCRIPT_MAX_CHARS = 28_000;
537
+
538
+ function currentDialogFromContext(ctx: ExtensionContext | undefined): string | undefined {
539
+ if (!ctx) return undefined;
540
+ let branch: unknown[];
541
+ try {
542
+ const maybeBranch = ctx.sessionManager.getBranch?.();
543
+ branch = Array.isArray(maybeBranch) ? maybeBranch : [...(maybeBranch ?? [])];
544
+ } catch {
545
+ return undefined;
546
+ }
547
+
548
+ const messages: { role: "user" | "assistant"; text: string }[] = [];
549
+ for (const entry of branch) {
550
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
551
+ const role = entry.message.role === "user" || entry.message.role === "assistant" ? entry.message.role : undefined;
552
+ if (!role) continue;
553
+ const text = stripDcpMarkers(visibleMessageText(entry.message.content, role)).trim();
554
+ if (!text) continue;
555
+ messages.push({ role, text });
556
+ }
557
+ if (messages.length === 0) return undefined;
558
+
559
+ const selected: string[] = [];
560
+ let used = 0;
561
+ let omitted = 0;
562
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
563
+ const formatted = formatDialogMessage(messages[idx]);
564
+ if (selected.length >= TRANSCRIPT_MAX_MESSAGES || (used + formatted.length > TRANSCRIPT_MAX_CHARS && selected.length > 0)) {
565
+ omitted = idx + 1;
566
+ break;
567
+ }
568
+ selected.unshift(formatted);
569
+ used += formatted.length;
570
+ }
571
+ return `${omitted > 0 ? `… ${omitted} earlier message(s) omitted …\n\n` : ""}${selected.join("\n\n")}`;
572
+ }
573
+
574
+ function formatDialogMessage(message: { role: "user" | "assistant"; text: string }): string {
575
+ return `${message.role === "user" ? "👤 You" : "🤖 Pi"}\n${message.text}`;
576
+ }
577
+
578
+ function stripDcpMarkers(text: string): string {
579
+ return text
580
+ .split(/\r?\n/u)
581
+ .map((line) => line.replace(DCP_MARKER_RE, "").trimEnd())
582
+ .join("\n")
583
+ .replace(/[ \t]+\n/gu, "\n")
584
+ .replace(/\n{3,}/gu, "\n\n")
585
+ .trim();
586
+ }
587
+
588
+ const DCP_MARKER_RE = /\[dcp(?:-[\w-]+)?\]:\s*#\s*\([^)]*\)/giu;
589
+
590
+ function visibleMessageText(value: unknown, role: "user" | "assistant"): string {
591
+ if (typeof value === "string") return value;
592
+ if (Array.isArray(value)) return value.map((item) => visibleContentBlockText(item, role)).filter(Boolean).join("\n");
593
+ return visibleContentBlockText(value, role);
594
+ }
595
+
596
+ function visibleContentBlockText(value: unknown, role: "user" | "assistant"): string {
597
+ if (typeof value === "string") return value;
598
+ if (!isRecord(value)) return "";
599
+ const type = typeof value.type === "string" ? value.type.toLowerCase() : "";
600
+ if (type.includes("tool") || type.includes("thinking")) return "";
601
+ if (typeof value.text === "string") return value.text;
602
+ if (typeof value.content === "string") return value.content;
603
+ if (Array.isArray(value.content)) return value.content.map((item) => visibleContentBlockText(item, role)).filter(Boolean).join("\n");
604
+ if (role === "user" && (type.includes("image") || type.includes("file"))) return `[${type || "attachment"}]`;
605
+ return "";
606
+ }
607
+
608
+ function isRecord(value: unknown): value is Record<string, unknown> {
609
+ return value !== null && typeof value === "object" && !Array.isArray(value);
610
+ }
611
+
612
+ function sessionSnapshot(ctx: ExtensionContext | undefined): SessionSnapshot | undefined {
613
+ if (!ctx) return undefined;
614
+ const manager = ctx.sessionManager;
615
+ return {
616
+ cwd: manager.getCwd?.() ?? ctx.cwd,
617
+ ...(manager.getSessionId?.() ? { sessionId: manager.getSessionId() } : {}),
618
+ ...(manager.getSessionFile?.() ? { sessionFile: manager.getSessionFile() } : {}),
619
+ ...(manager.getSessionName?.() ? { sessionName: manager.getSessionName() } : {}),
620
+ };
621
+ }
622
+
623
+ function notify(ctx: { hasUI?: boolean; ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }, message: string, type: "info" | "warning" | "error" = "info"): void {
624
+ if (ctx.hasUI) ctx.ui?.notify?.(message, type);
625
+ else console.error(`[telegram-mirror] ${message}`);
626
+ }
627
+
422
628
  function errorMessage(error: unknown): string {
423
629
  return error instanceof Error ? error.message : String(error);
424
630
  }
@@ -57,12 +57,19 @@ export interface InstanceInfo {
57
57
  cwd: string;
58
58
  /** Human-friendly label, e.g. basename of cwd + short pid suffix. */
59
59
  label: string;
60
+ /** Current pi session id, when available. */
61
+ sessionId?: string;
62
+ /** Current pi session file, when available. */
63
+ sessionFile?: string;
64
+ /** Human-friendly pi session name, when set. */
65
+ sessionName?: string;
60
66
  /** ms-since-epoch when this instance started. */
61
67
  started: number;
62
68
  }
63
69
 
64
70
  export type IpcMessage =
65
71
  | { type: "register"; info: InstanceInfo }
72
+ | { type: "instance_update"; info: InstanceInfo }
66
73
  | { type: "registered"; leader: InstanceInfo; activeId: string | null }
67
74
  | { type: "ping"; t: number }
68
75
  | { type: "pong"; t: number }
@@ -414,6 +421,19 @@ export function buildInstanceId(): { id: string; info: InstanceInfo } {
414
421
  };
415
422
  }
416
423
 
424
+ export function updateInstanceInfo(base: InstanceInfo, patch: Partial<Pick<InstanceInfo, "cwd" | "sessionId" | "sessionFile" | "sessionName">>): InstanceInfo {
425
+ const cwd = patch.cwd ?? base.cwd;
426
+ const cwdBase = path.basename(cwd) || cwd;
427
+ return {
428
+ ...base,
429
+ cwd,
430
+ label: `${cwdBase} (#${base.pid})`,
431
+ ...(patch.sessionId !== undefined ? { sessionId: patch.sessionId } : base.sessionId ? { sessionId: base.sessionId } : {}),
432
+ ...(patch.sessionFile !== undefined ? { sessionFile: patch.sessionFile } : base.sessionFile ? { sessionFile: base.sessionFile } : {}),
433
+ ...(patch.sessionName !== undefined ? { sessionName: patch.sessionName } : base.sessionName ? { sessionName: base.sessionName } : {}),
434
+ };
435
+ }
436
+
417
437
  /** Generate a unique request id for command/query correlation. */
418
438
  export function generateReqId(): string {
419
439
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;