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.
- package/apps/desktop-tauri/README.md +103 -0
- package/apps/desktop-tauri/bin/pix-desktop.mjs +89 -0
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +29 -0
- package/dist/app/input/input-paste-handler.d.ts +1 -1
- package/dist/app/input/input-paste-handler.js +6 -5
- package/dist/app/model/model-usage-status.js +4 -27
- package/dist/app/rendering/render-controller.js +12 -8
- package/dist/app/screen/mouse-controller.d.ts +1 -0
- package/dist/app/screen/mouse-controller.js +13 -16
- package/external/pi-tools-suite/src/config.ts +43 -0
- package/external/pi-tools-suite/src/dcp/commands.ts +1 -1
- package/external/pi-tools-suite/src/dcp/index.ts +21 -1
- package/external/pi-tools-suite/src/dcp/state.ts +225 -3
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +5 -0
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/external/pi-tools-suite/src/telegram-mirror/README.md +168 -0
- package/external/pi-tools-suite/src/telegram-mirror/bot.ts +228 -0
- package/external/pi-tools-suite/src/telegram-mirror/events.ts +94 -0
- package/external/pi-tools-suite/src/telegram-mirror/format.ts +120 -0
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +424 -0
- package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +419 -0
- package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +408 -0
- package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +214 -0
- package/package.json +14 -3
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
34
|
+
this.pasteBufferParts = [];
|
|
35
35
|
}
|
|
36
36
|
appendBracketedPasteText(text) {
|
|
37
|
-
|
|
37
|
+
if (text)
|
|
38
|
+
this.pasteBufferParts.push(text);
|
|
38
39
|
}
|
|
39
40
|
endBracketedPaste() {
|
|
40
41
|
this.host.inputEditor.endBracketedPaste();
|
|
41
|
-
const text = this.
|
|
42
|
-
this.
|
|
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
|
|
818
|
+
return `${days}d${hours}h`;
|
|
819
819
|
if (hours > 0)
|
|
820
|
-
return `${hours}h
|
|
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(
|
|
830
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
})
|