pi-ui-extend 0.1.17 → 0.1.18

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.
@@ -0,0 +1,103 @@
1
+ # Pix Desktop (Tauri)
2
+
3
+ Tauri-based desktop UI for the Pi coding agent. It is a sibling workspace of the `pix` terminal app and uses the same `@earendil-works/pi-coding-agent` SDK through a Node sidecar.
4
+
5
+ > **Status — current prototype.** React talks to a Rust Tauri host via `rpc_call` / `rpc_subscribe`. Rust proxies line-delimited SDK-shaped JSON to a custom Node dispatcher. Implemented: workspace picker, persistent sessions, tabbed chat with per-workspace tab restore, expanded tool-call cards, history loading, SDK/pi-tools-suite slash-command discovery with extension argument completions, path/general composer autocomplete, image attachments via paste/drop/file picker, Web Speech voice dictation, captured `!shell` commands, raw `!!` PTY terminal surface, extension UI request dialogs with select search/timeouts, toasts/widgets/status plus session-scoped lifecycle, explicit degraded handling for custom/component extension UI, real pi-tools-suite interactive command smoke coverage, desktop-native `/model`/`/compact`/`/undo`, streaming/abort, and status bar. There is intentionally no sidebar.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ React (Vite, port 1420)
11
+ │ invoke("rpc_call", { cmd }) / invoke("rpc_subscribe", { onEvent })
12
+
13
+ Tauri Rust host (src-tauri/)
14
+ │ JSONL over stdio, SDK flat shape { id?, type, ... }
15
+
16
+ Node sidecar (sidecar/src/)
17
+ │ custom dispatcher + @earendil-works/pi-coding-agent SDK
18
+
19
+ AgentSession / SessionManager
20
+ ```
21
+
22
+ The frontend never imports or calls the SDK directly. Rust owns native APIs and sidecar process management; the sidecar owns SDK runtime/session operations.
23
+
24
+ ## Layout
25
+
26
+ ```
27
+ apps/desktop-tauri/
28
+ ├── src/ # React frontend
29
+ │ ├── App.tsx # workspace picker, topbar, tabs, chat, history transform
30
+ │ ├── App.css # Tokyo-Night dark theme
31
+ │ └── tools/ # tool-call renderer registry and renderers
32
+ ├── sidecar/src/ # Node SDK bridge
33
+ │ ├── main.ts # create runtime, switch cwd, run dispatcher
34
+ │ ├── dispatcher.ts # command switch and event subscription rebinding
35
+ │ ├── pix-handlers.ts # pix:list_sessions
36
+ │ ├── framing.ts # strict LF JSONL framing
37
+ │ └── protocol.ts # wire types
38
+ └── src-tauri/src/ # Rust host and sidecar bridge
39
+ ```
40
+
41
+ ## Wire protocol
42
+
43
+ This is **not JSON-RPC 2.0**. The sidecar uses the SDK-style flat JSONL protocol:
44
+
45
+ - Command: `{ "id": "req-1", "type": "prompt", "message": "hi", "images": [] }`
46
+ - Response: `{ "id": "req-1", "type": "response", "command": "prompt", "success": true, "data": ... }`
47
+ - Event: `{ "type": "agent_start" | "message_update" | "tool_execution_*" | ... }`
48
+
49
+ Implemented sidecar commands include `prompt`, `abort`, `get_state`, `get_messages`, `get_session_stats`, `get_commands`, `get_command_completions`, `extension_ui_response`, `get_models`, `set_model`, `compact`, `undo_last_turn`, `new_session`, `switch_session`, `set_session_name`, `pix:list_sessions`, and `pix:set_cwd`. The sidecar emits `extension_ui_request` events for extension `ctx.ui.*` calls, and the frontend answers dialog methods with `extension_ui_response`. The Rust host also exposes native `run_shell`, `complete_path`, and `pty_*` commands for the desktop `!cmd` flow, composer path autocomplete, and raw `!!` terminal sessions.
50
+
51
+ ## Setup and run
52
+
53
+ From the repo root:
54
+
55
+ ```bash
56
+ npm install --ignore-scripts
57
+ npm run desktop:icons
58
+ npm run desktop:dev
59
+ ```
60
+
61
+ On first launch, choose a project folder. Pix Desktop stores the selected workspace in localStorage under `pix-desktop.workspace`, stores open tabs per cwd under `pix-desktop.tabs:<cwd>`, and resumes the saved active tab when possible.
62
+
63
+ ## Verification
64
+
65
+ ```bash
66
+ npm run desktop:check
67
+ ```
68
+
69
+ Equivalent expanded checks:
70
+
71
+ ```bash
72
+ npm --prefix apps/desktop-tauri run build
73
+ npm --prefix apps/desktop-tauri/sidecar run check
74
+ cargo check --manifest-path apps/desktop-tauri/src-tauri/Cargo.toml
75
+ ```
76
+
77
+ ## Env overrides
78
+
79
+ | Variable | Purpose |
80
+ | --- | --- |
81
+ | `PIX_SIDECAR_CMD` | Command to spawn, default `node`. |
82
+ | `PIX_SIDECAR_ARGS` | Whitespace-separated args, overrides default `--import tsx`. |
83
+ | `PIX_SIDECAR_PATH` | Explicit sidecar entry path. |
84
+ | `PIX_SIDECAR_AGENT_DIR` | Pi agent dir for auth/skills/extensions, default `~/.pi/agent`. |
85
+ | `PIX_SIDECAR_SESSION_MODE` | `persistent` by default; use `in-memory` for ephemeral tests. |
86
+ | `RUST_LOG` | Rust tracing filter. |
87
+
88
+ ## Current UX notes
89
+
90
+ - Tabs are the only session navigation surface; the old sidebar was removed.
91
+ - Open tabs and the active tab are restored per workspace across Tauri restarts.
92
+ - Typing `/` opens a slash-command menu. Desktop built-ins (`/help`, `/new`, `/clear`, `/refresh`, `/abort`) are merged with SDK-discovered extension, prompt-template, and skill commands from `get_commands`; selecting a discovered command sends it through `prompt` with arguments preserved. When an extension command exposes `getArgumentCompletions`, the frontend debounces `get_command_completions` and shows argument suggestions in the same keyboard/click popup.
93
+ - Path/general autocomplete is handled locally through the Rust `complete_path` helper, scoped to the selected workspace. It completes `@path` mentions in normal messages, path-like `!cmd` shell tokens, and generic slash-command arguments when no richer extension/model completion is available.
94
+ - Images can be attached from the composer with the image button, paste, or drag/drop. The frontend previews them locally, sends SDK `ImageContent[]` through the sidecar `prompt` command, and keeps file attachments as text/path mentions for now.
95
+ - Voice dictation is available from the composer mic button when the current WebView exposes `SpeechRecognition`/`webkitSpeechRecognition`; final transcripts are appended to the composer. Unsupported/error states are shown inline. Offline Vosk parity remains future work.
96
+ - Extension commands can request simple UI through the RPC-style `extension_ui_request` surface: `select`, `confirm`, `input`, and `editor` show modal dialogs. Select dialogs include local search, long-list scrolling, option counts, and timeout countdown/self-cancel affordances when an extension passes `dialogOpts.timeout`; `notify` shows toasts; `setWidget` renders scroll-contained text widgets above/below the composer; `setStatus` and working-indicator APIs add status-bar entries; `setHeader`/`setFooter`, component widgets, `custom()`, `setEditorComponent()`, and extension autocomplete providers now have explicit degraded desktop behavior/status instead of silent no-ops; extension widgets/status/dialogs are cleared on session/workspace switch so stale UI does not leak across tabs; `set_editor_text` fills the composer.
97
+ - Real `pi-tools-suite` command smoke checks have exercised `/prompt-commands` (`select`, `notify`, `input` cancelled before mutation) and `/subagent-preset-config` (`select` cancelled) through the desktop sidecar path. Keep `PIX_SIDECAR_SESSION_MODE=in-memory` for these checks, but do not redirect `HOME` unless extension discovery is configured for that home; otherwise user extensions are not loaded and slash commands fall through to normal prompts.
98
+ - Desktop-native interactive built-ins are available for commands that are not prompt-invokable: `/model` opens a model picker and `/model <provider/id>` sets directly; `/compact [instructions]` runs SDK compaction; `/undo` navigates back to the latest user turn and restores returned editor text when available.
99
+ - Typing `!command` runs a short non-interactive shell command in the selected workspace and renders captured stdout/stderr as a shell tool card. Typing `!!command` opens an xterm.js-backed PTY panel in the current workspace for interactive/raw terminal programs; bare `!!` opens the user's shell.
100
+ - Tool-call cards include specialized renderers for shell/file/patch/todo/web/folder operations plus repo-index tools, ast-grep/apply, question prompts, subagent orchestration, context compression, and skill activation.
101
+ - Switching tabs/folders and closing tabs are blocked while an agent run is streaming, because the sidecar has one active SDK session subscription.
102
+ - On session switch or workspace restore, the UI calls `get_messages` and transforms SDK messages into sanitized chat messages, filtering reasoning/image internals and attaching tool results to tool-call cards.
103
+ - Sidecar logs must go to stderr only; stdout is reserved for JSONL protocol records.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pix-desktop — CLI launcher for the Tauri-based Pix Desktop UI.
4
+ *
5
+ * Behaves like the regular `pix` CLI: launch from any project folder and
6
+ * Pix Desktop treats that folder as the workspace cwd, scoping sessions to
7
+ * it just like the terminal pix does.
8
+ *
9
+ * Usage:
10
+ * cd /path/to/project
11
+ * pix-desktop
12
+ *
13
+ * Environment:
14
+ * PIX_DESKTOP_BIN Path to a specific Tauri binary (overrides discovery).
15
+ * PIX_DESKTOP_DEBUG Set to "1" to log resolved binary/cwd before launch.
16
+ *
17
+ * Build the binary first via one of:
18
+ * cd apps/desktop-tauri && npm run tauri:dev # development (with hot reload)
19
+ * cd apps/desktop-tauri && npm run tauri:build # production bundle
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { platform } from "node:os";
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const tauriTarget = join(here, "..", "src-tauri", "target");
30
+
31
+ const exeSuffix = platform() === "win32" ? ".exe" : "";
32
+ const macBundle = join(
33
+ tauriTarget,
34
+ "release",
35
+ "bundle",
36
+ "macos",
37
+ "Pix.app",
38
+ "Contents",
39
+ "MacOS",
40
+ "pix-desktop",
41
+ );
42
+
43
+ // Discovery order: env override → debug → release raw → release macOS bundle.
44
+ const candidates = [
45
+ join(tauriTarget, "debug", `pix-desktop${exeSuffix}`),
46
+ join(tauriTarget, "release", `pix-desktop${exeSuffix}`),
47
+ macBundle,
48
+ ];
49
+
50
+ if (process.env.PIX_DESKTOP_BIN) candidates.unshift(process.env.PIX_DESKTOP_BIN);
51
+
52
+ const bin = candidates.find((p) => existsSync(p));
53
+
54
+ if (!bin) {
55
+ console.error("pix-desktop: Tauri binary not found. Build it first:");
56
+ console.error("");
57
+ console.error(" cd apps/desktop-tauri && npm run tauri:dev # development");
58
+ console.error(" cd apps/desktop-tauri && npm run tauri:build # production");
59
+ console.error("");
60
+ console.error("Or set PIX_DESKTOP_BIN=/path/to/pix-desktop to point at a custom binary.");
61
+ process.exit(127);
62
+ }
63
+
64
+ if (process.env.PIX_DESKTOP_DEBUG) {
65
+ console.error(`[pix-desktop] binary: ${bin}`);
66
+ console.error(`[pix-desktop] cwd: ${process.cwd()}`);
67
+ }
68
+
69
+ const child = spawn(bin, process.argv.slice(2), {
70
+ stdio: "inherit",
71
+ cwd: process.cwd(),
72
+ env: process.env,
73
+ });
74
+
75
+ child.on("exit", (code, signal) => {
76
+ if (signal) {
77
+ // Re-raise the signal in the launcher so wrapping shells see it.
78
+ try { process.kill(process.pid, signal); } catch { /* ignore */ }
79
+ process.exit(128 + 15); // SIGTERM-ish
80
+ }
81
+ process.exit(code ?? 0);
82
+ });
83
+
84
+ // Forward common signals to the child so Ctrl-C cleans up the Tauri window.
85
+ for (const sig of ["SIGINT", "SIGTERM"]) {
86
+ process.on(sig, () => {
87
+ if (!child.killed) child.kill(sig);
88
+ });
89
+ }
@@ -37,6 +37,7 @@ export declare class AppInputController {
37
37
  handleChunk(chunk: Buffer): void;
38
38
  private consumeSharedEditorShiftEnter;
39
39
  private drainInputBuffer;
40
+ private consumeBracketedPastePayload;
40
41
  private getEscapeSequences;
41
42
  private isPendingEscapeSequence;
42
43
  private consumeEscapeSequence;
@@ -42,6 +42,8 @@ export class AppInputController {
42
42
  }
43
43
  drainInputBuffer() {
44
44
  while (this.inputBuffer.length > 0) {
45
+ if (this.consumeBracketedPastePayload())
46
+ continue;
45
47
  const mouseMatch = /^\x1b\[<(\d+);(-?\d+);(-?\d+)([mM])/.exec(this.inputBuffer);
46
48
  if (mouseMatch) {
47
49
  this.inputBuffer = this.inputBuffer.slice(mouseMatch[0].length);
@@ -85,6 +87,23 @@ export class AppInputController {
85
87
  this.handleChar(char);
86
88
  }
87
89
  }
90
+ consumeBracketedPastePayload() {
91
+ if (!this.host.inputEditor.isInBracketedPaste)
92
+ return false;
93
+ const endSequence = "\x1b[201~";
94
+ const endIndex = this.inputBuffer.indexOf(endSequence);
95
+ if (endIndex === 0)
96
+ return false;
97
+ const payloadEnd = endIndex === -1
98
+ ? safeBracketedPastePayloadLength(this.inputBuffer, endSequence)
99
+ : endIndex;
100
+ if (payloadEnd === 0)
101
+ return false;
102
+ const payload = this.inputBuffer.slice(0, payloadEnd);
103
+ this.inputBuffer = this.inputBuffer.slice(payloadEnd);
104
+ this.pasteHandler.appendBracketedPasteText(normalizeBracketedPastePayload(payload));
105
+ return true;
106
+ }
88
107
  getEscapeSequences() {
89
108
  return [
90
109
  ["\x1b[13;2u", () => this.insertInputNewline()],
@@ -425,3 +444,13 @@ export class AppInputController {
425
444
  return this.host.isShiftPressed?.() ?? isNativeShiftPressed();
426
445
  }
427
446
  }
447
+ function normalizeBracketedPastePayload(payload) {
448
+ return payload.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
449
+ }
450
+ function safeBracketedPastePayloadLength(buffer, endSequence) {
451
+ for (let length = Math.min(buffer.length, endSequence.length - 1); length > 0; length--) {
452
+ if (endSequence.startsWith(buffer.slice(buffer.length - length)))
453
+ return buffer.length - length;
454
+ }
455
+ return buffer.length;
456
+ }
@@ -7,7 +7,7 @@ export type InputPasteHost = {
7
7
  };
8
8
  export declare class InputPasteHandler {
9
9
  private readonly host;
10
- private pasteBuffer;
10
+ private pasteBufferParts;
11
11
  private readonly recentPasteFingerprints;
12
12
  private suppressImagePathPasteUntil;
13
13
  constructor(host: InputPasteHost);
@@ -7,7 +7,7 @@ import { normalizePastedTextForDuplicateKey } from "../rendering/render-text.js"
7
7
  const PASTE_FINGERPRINT_PREFIX_CHARS = 64 * 1024;
8
8
  export class InputPasteHandler {
9
9
  host;
10
- pasteBuffer = "";
10
+ pasteBufferParts = [];
11
11
  recentPasteFingerprints = new Map();
12
12
  suppressImagePathPasteUntil = 0;
13
13
  constructor(host) {
@@ -31,15 +31,16 @@ export class InputPasteHandler {
31
31
  }
32
32
  beginBracketedPaste() {
33
33
  this.host.inputEditor.beginBracketedPaste();
34
- this.pasteBuffer = "";
34
+ this.pasteBufferParts = [];
35
35
  }
36
36
  appendBracketedPasteText(text) {
37
- this.pasteBuffer += text;
37
+ if (text)
38
+ this.pasteBufferParts.push(text);
38
39
  }
39
40
  endBracketedPaste() {
40
41
  this.host.inputEditor.endBracketedPaste();
41
- const text = this.pasteBuffer;
42
- this.pasteBuffer = "";
42
+ const text = this.pasteBufferParts.join("");
43
+ this.pasteBufferParts = [];
43
44
  this.handlePasteEnd(text);
44
45
  }
45
46
  async handleClipboardImagePaste() {
@@ -815,9 +815,9 @@ function formatDurationShort(resetAt, now) {
815
815
  const hours = Math.floor((totalMinutes % 1440) / 60);
816
816
  const minutes = totalMinutes % 60;
817
817
  if (days > 0)
818
- return `${days}d ${hours}h`;
818
+ return `${days}d${hours}h`;
819
819
  if (hours > 0)
820
- return `${hours}h ${minutes}m`;
820
+ return `${hours}h${minutes}m`;
821
821
  return `${minutes}m`;
822
822
  }
823
823
  function maskCredential(value) {
@@ -826,29 +826,6 @@ function maskCredential(value) {
826
826
  return visible ? "****" : "unknown";
827
827
  return `${visible.slice(0, 4)}****${visible.slice(-4)}`;
828
828
  }
829
- function formatUsageWindow(prefix, window, now) {
830
- const resetLabel = prefix === "H" ? formatResetTime(window.resetAt, now) : formatGlobalResetLabel(window.resetAt, now);
831
- return `${window.remainingPercent}% ${formatCompactProgressBar(window.remainingPercent)} ${resetLabel}`;
832
- }
833
- function formatGlobalResetLabel(resetAt, now) {
834
- if (resetAt <= now)
835
- return "reset";
836
- return resetAt - now <= DAY_SECONDS * 1000 ? formatResetTime(resetAt, now) : formatResetDate(resetAt, now);
837
- }
838
- function formatResetTime(resetAt, now) {
839
- if (resetAt <= now)
840
- return "reset";
841
- return new Date(resetAt).toLocaleTimeString("ru-RU", {
842
- hour: "2-digit",
843
- minute: "2-digit",
844
- hourCycle: "h23",
845
- });
846
- }
847
- function formatResetDate(resetAt, now) {
848
- if (resetAt <= now)
849
- return "reset";
850
- return new Date(resetAt).toLocaleDateString("ru-RU", {
851
- day: "2-digit",
852
- month: "2-digit",
853
- });
829
+ function formatUsageWindow(_prefix, window, now) {
830
+ return `${window.remainingPercent}% ${formatCompactProgressBar(window.remainingPercent)} ${formatDurationShort(window.resetAt, now)}`;
854
831
  }
@@ -293,15 +293,19 @@ export class AppRenderController {
293
293
  this.deps.mouseController.statusThinkingTarget = this.deps.statusLineRenderer.thinkingTarget(statusLayout.text, statusRow);
294
294
  this.deps.mouseController.statusContextTarget = this.deps.statusLineRenderer.contextTarget(statusLayout.text, statusRow, statusLayout);
295
295
  this.deps.mouseController.statusModelUsageTarget = this.deps.statusLineRenderer.modelUsageTarget(statusLayout.text, statusRow, statusLayout);
296
- this.deps.mouseController.statusDraftQueueTarget = widgetLayout ? this.deps.statusLineRenderer.draftQueueTarget?.(widgetLayout, widgetRow) : undefined;
297
- this.deps.mouseController.statusUserJumpTarget = widgetLayout ? this.deps.statusLineRenderer.userJumpTarget?.(widgetLayout, widgetRow) : undefined;
298
- this.deps.mouseController.statusThinkingExpandTarget = widgetLayout ? this.deps.statusLineRenderer.thinkingExpandTarget?.(widgetLayout, widgetRow) : undefined;
299
- this.deps.mouseController.statusCompactToolsTarget = widgetLayout ? this.deps.statusLineRenderer.compactToolsTarget?.(widgetLayout, widgetRow) : undefined;
300
- this.deps.mouseController.statusTerminalBellSoundTarget = widgetLayout ? this.deps.statusLineRenderer.terminalBellSoundTarget?.(widgetLayout, widgetRow) : undefined;
296
+ if (widgetLayout) {
297
+ this.deps.mouseController.statusDraftQueueTarget = this.deps.statusLineRenderer.draftQueueTarget?.(widgetLayout, widgetRow);
298
+ this.deps.mouseController.statusUserJumpTarget = this.deps.statusLineRenderer.userJumpTarget?.(widgetLayout, widgetRow);
299
+ this.deps.mouseController.statusThinkingExpandTarget = this.deps.statusLineRenderer.thinkingExpandTarget?.(widgetLayout, widgetRow);
300
+ this.deps.mouseController.statusCompactToolsTarget = this.deps.statusLineRenderer.compactToolsTarget?.(widgetLayout, widgetRow);
301
+ this.deps.mouseController.statusTerminalBellSoundTarget = this.deps.statusLineRenderer.terminalBellSoundTarget?.(widgetLayout, widgetRow);
302
+ }
301
303
  this.deps.mouseController.statusSessionTarget = this.deps.statusLineRenderer.sessionTarget(statusLayout.text, statusRow, statusLayout.sessionLabel, statusLayout.workspaceLabel);
302
- this.deps.mouseController.statusPromptEnhancerTarget = widgetLayout ? this.deps.statusLineRenderer.promptEnhancerTarget(widgetLayout, widgetRow) : undefined;
303
- this.deps.mouseController.statusVoiceMicTarget = widgetLayout ? this.deps.statusLineRenderer.voiceMicTarget(widgetLayout, widgetRow) : undefined;
304
- this.deps.mouseController.statusVoiceLanguageTarget = widgetLayout ? this.deps.statusLineRenderer.voiceLanguageTarget(widgetLayout, widgetRow) : undefined;
304
+ if (widgetLayout) {
305
+ this.deps.mouseController.statusPromptEnhancerTarget = this.deps.statusLineRenderer.promptEnhancerTarget(widgetLayout, widgetRow);
306
+ this.deps.mouseController.statusVoiceMicTarget = this.deps.statusLineRenderer.voiceMicTarget(widgetLayout, widgetRow);
307
+ this.deps.mouseController.statusVoiceLanguageTarget = this.deps.statusLineRenderer.voiceLanguageTarget(widgetLayout, widgetRow);
308
+ }
305
309
  this.deps.mouseController.renderedRowTexts.set(statusRow, statusLayout.text);
306
310
  }
307
311
  renderVoiceProgressOverlay(message, width, rows) {
@@ -154,6 +154,7 @@ export declare class AppMouseController {
154
154
  private handleStatusContextClick;
155
155
  private handleStatusModelUsageClick;
156
156
  private handleStatusUserJumpClick;
157
+ private handleInputBorderStatusClick;
157
158
  private openStatusUserJumpMenu;
158
159
  private handleStatusDraftQueueClick;
159
160
  private handleStatusThinkingExpandClick;
@@ -57,6 +57,8 @@ export class AppMouseController {
57
57
  if (this.handleInputScrollBar(event))
58
58
  return;
59
59
  this.showClickFlashOnPress(event);
60
+ if (event.button === 0 && !event.released && this.handleInputBorderStatusClick(event))
61
+ return;
60
62
  if (this.handleMouseSelection(event))
61
63
  return;
62
64
  if (this.withClickFlash(event, () => this.handleImageClick(event)))
@@ -73,24 +75,8 @@ export class AppMouseController {
73
75
  return;
74
76
  if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusModelUsageClick(event)))
75
77
  return;
76
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusDraftQueueClick(event)))
77
- return;
78
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusUserJumpClick(event)))
79
- return;
80
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusThinkingExpandClick(event)))
81
- return;
82
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusCompactToolsClick(event)))
83
- return;
84
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusTerminalBellSoundClick(event)))
85
- return;
86
78
  if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusSessionClick(event)))
87
79
  return;
88
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusPromptEnhancerClick(event)))
89
- return;
90
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusVoiceMicClick(event)))
91
- return;
92
- if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusVoiceLanguageClick(event)))
93
- return;
94
80
  if (event.button === 0 && this.withClickFlash(event, () => this.handleExtensionInputClick(event)))
95
81
  return;
96
82
  if (event.button === 0 && this.withClickFlash(event, () => this.handleInputClick(event)))
@@ -290,6 +276,7 @@ export class AppMouseController {
290
276
  this.statusUserJumpTarget,
291
277
  this.statusThinkingExpandTarget,
292
278
  this.statusCompactToolsTarget,
279
+ this.statusTerminalBellSoundTarget,
293
280
  this.statusSessionTarget,
294
281
  this.statusPromptEnhancerTarget,
295
282
  this.statusVoiceMicTarget,
@@ -476,6 +463,16 @@ export class AppMouseController {
476
463
  void this.openStatusUserJumpMenu();
477
464
  return true;
478
465
  }
466
+ handleInputBorderStatusClick(event) {
467
+ return this.handleStatusDraftQueueClick(event)
468
+ || this.handleStatusUserJumpClick(event)
469
+ || this.handleStatusThinkingExpandClick(event)
470
+ || this.handleStatusCompactToolsClick(event)
471
+ || this.handleStatusTerminalBellSoundClick(event)
472
+ || this.handleStatusPromptEnhancerClick(event)
473
+ || this.handleStatusVoiceMicClick(event)
474
+ || this.handleStatusVoiceLanguageClick(event);
475
+ }
479
476
  async openStatusUserJumpMenu() {
480
477
  try {
481
478
  const refreshPromise = this.host.refreshUserMessageJumpMenuItems?.();
@@ -5,16 +5,24 @@ import { parse as parseJsonc } from "jsonc-parser";
5
5
 
6
6
  import { DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC } from "./default-pi-tools-suite-config.js";
7
7
 
8
+ export interface TelegramMirrorConfig {
9
+ enabled: boolean;
10
+ botToken: string;
11
+ chatId: number;
12
+ }
13
+
8
14
  export interface PiToolsSuiteConfig {
9
15
  enabled: boolean;
10
16
  disabledModules: string[];
11
17
  todoThinking: boolean;
18
+ telegramMirror?: TelegramMirrorConfig;
12
19
  }
13
20
 
14
21
  type MutableConfig = {
15
22
  enabled: boolean;
16
23
  disabledModules: Set<string>;
17
24
  todoThinking: boolean;
25
+ telegramMirror: TelegramMirrorConfig | undefined;
18
26
  };
19
27
 
20
28
  type Env = Record<string, string | undefined>;
@@ -52,6 +60,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
52
60
  return value !== null && typeof value === "object" && !Array.isArray(value);
53
61
  }
54
62
 
63
+ function normalizeTelegramMirror(raw: unknown): TelegramMirrorConfig | undefined {
64
+ if (!isRecord(raw)) return undefined;
65
+ const botToken = typeof raw.botToken === "string" ? raw.botToken.trim() : "";
66
+ if (!botToken) return undefined;
67
+
68
+ let chatId: number | undefined;
69
+ if (typeof raw.chatId === "number" && Number.isFinite(raw.chatId) && Number.isInteger(raw.chatId)) {
70
+ chatId = raw.chatId;
71
+ } else if (typeof raw.chatId === "string") {
72
+ const trimmed = raw.chatId.trim();
73
+ if (/^-?\d+$/.test(trimmed)) {
74
+ const parsed = Number(trimmed);
75
+ if (Number.isFinite(parsed) && Number.isInteger(parsed)) chatId = parsed;
76
+ }
77
+ }
78
+ if (chatId === undefined) return undefined;
79
+
80
+ const enabled = typeof raw.enabled === "boolean" ? raw.enabled : true;
81
+ return { enabled, botToken, chatId };
82
+ }
83
+
55
84
  function boolFromEnv(value: string | undefined): boolean | undefined {
56
85
  if (value === undefined) return undefined;
57
86
  const normalized = value.trim().toLowerCase();
@@ -125,6 +154,9 @@ function mergeConfigLayer(config: MutableConfig, raw: Record<string, unknown>, k
125
154
  }
126
155
  }
127
156
 
157
+ const telegramMirror = normalizeTelegramMirror(raw.telegramMirror);
158
+ if (telegramMirror) config.telegramMirror = telegramMirror;
159
+
128
160
  return config;
129
161
  }
130
162
 
@@ -166,6 +198,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
166
198
  enabled: true,
167
199
  disabledModules: new Set([...DEFAULT_DISABLED_MODULES].filter((name) => knownModules.has(name))),
168
200
  todoThinking: false,
201
+ telegramMirror: undefined,
169
202
  };
170
203
  const userConfigPath = getPiToolsSuiteUserConfigPath(options.homeDir);
171
204
 
@@ -184,5 +217,15 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
184
217
  enabled: config.enabled,
185
218
  disabledModules: [...config.disabledModules].sort(),
186
219
  todoThinking: config.todoThinking,
220
+ ...(config.telegramMirror ? { telegramMirror: config.telegramMirror } : {}),
187
221
  };
188
222
  }
223
+
224
+ /**
225
+ * Load only the telegram-mirror section from the pi-tools-suite config layers.
226
+ * Returns undefined when the section is missing or invalid (botToken empty /
227
+ * chatId non-integer).
228
+ */
229
+ export function loadTelegramMirrorConfig(options: { cwd?: string; env?: Env; homeDir?: string } = {}): TelegramMirrorConfig | undefined {
230
+ return loadPiToolsSuiteConfig([], options).telegramMirror;
231
+ }
@@ -198,7 +198,7 @@ function handleContext(ctx: ExtensionCommandContext, state: DcpState): void {
198
198
 
199
199
  lines.push("")
200
200
  lines.push("Session Stats:")
201
- lines.push(` Tool calls tracked: ${fmt(state.toolCalls.size)}`)
201
+ lines.push(` Tool calls tracked: ${fmt(state.totalToolCallCount)} (${fmt(state.toolCalls.size)} in memory)`)
202
202
  lines.push(` Pruned tools: ${fmt(state.prunedToolIds.size)}`)
203
203
  lines.push(` Compression blocks: ${state.compressionBlocks.filter((b) => b.active).length}`)
204
204
  lines.push(` Tokens saved (estimated): ${fmt(state.tokensSaved)}`)
@@ -9,6 +9,7 @@ import {
9
9
  resetState,
10
10
  createInputFingerprint,
11
11
  serializeState,
12
+ hashSerializedState,
12
13
  restoreState,
13
14
  type DcpState,
14
15
  } from "./state.js"
@@ -44,12 +45,26 @@ import { safeGetContextUsage } from "../context-usage.js"
44
45
  // Helpers
45
46
  // ---------------------------------------------------------------------------
46
47
 
48
+ /**
49
+ * Hash of the last persisted dcp-state snapshot. Used to skip appending
50
+ * identical snapshots when saveState is called repeatedly without state change.
51
+ */
52
+ let lastPersistedStateHash: string | undefined
53
+
47
54
  /**
48
55
  * Persist the current DCP runtime state as a custom session entry so it
49
56
  * survives session restarts and pi process restarts.
57
+ *
58
+ * Deduplication: serializes, hashes, and skips the append when the hash
59
+ * matches the previously persisted snapshot. This avoids writing identical
60
+ * multi-KB entries on every context event / nudge reapply.
50
61
  */
51
62
  function saveState(pi: ExtensionAPI, state: DcpState): void {
52
- pi.appendEntry("dcp-state", serializeState(state))
63
+ const serialized = serializeState(state)
64
+ const hash = hashSerializedState(serialized)
65
+ if (hash === lastPersistedStateHash) return
66
+ lastPersistedStateHash = hash
67
+ pi.appendEntry("dcp-state", serialized)
53
68
  }
54
69
 
55
70
  function annotateMessagesWithBranchEntryIds(messages: any[], ctx: ExtensionContext): void {
@@ -147,6 +162,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
147
162
  // Reset to a clean slate first.
148
163
  resetState(state)
149
164
 
165
+ // Reset dedup hash so the first save after restore always writes.
166
+ lastPersistedStateHash = undefined
167
+
150
168
  // Re-apply config baseline so manual mode survives a session_start reset.
151
169
  if (config.manualMode.enabled) {
152
170
  state.manualMode = true
@@ -197,6 +215,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
197
215
  timestamp: 0,
198
216
  tokenEstimate: 0,
199
217
  })
218
+ state.totalToolCallCount++
200
219
  }
201
220
  })
202
221
 
@@ -228,6 +247,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
228
247
  outputText,
229
248
  outputDetails: event.details,
230
249
  })
250
+ state.totalToolCallCount++
231
251
  }
232
252
 
233
253
  })