pi-ui-extend 0.1.15 → 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.
Files changed (92) hide show
  1. package/apps/desktop-tauri/README.md +103 -0
  2. package/apps/desktop-tauri/bin/pix-desktop.mjs +89 -0
  3. package/dist/app/app.d.ts +2 -0
  4. package/dist/app/app.js +21 -6
  5. package/dist/app/commands/command-controller.js +1 -0
  6. package/dist/app/commands/command-host.d.ts +2 -0
  7. package/dist/app/commands/command-navigation-actions.d.ts +9 -0
  8. package/dist/app/commands/command-navigation-actions.js +62 -3
  9. package/dist/app/commands/command-registry.d.ts +1 -0
  10. package/dist/app/commands/command-registry.js +8 -0
  11. package/dist/app/constants.d.ts +0 -1
  12. package/dist/app/constants.js +0 -1
  13. package/dist/app/icons.d.ts +1 -0
  14. package/dist/app/icons.js +2 -0
  15. package/dist/app/input/input-action-controller.d.ts +1 -0
  16. package/dist/app/input/input-action-controller.js +5 -4
  17. package/dist/app/input/input-controller.d.ts +1 -0
  18. package/dist/app/input/input-controller.js +29 -0
  19. package/dist/app/input/input-paste-handler.d.ts +1 -1
  20. package/dist/app/input/input-paste-handler.js +6 -5
  21. package/dist/app/model/model-usage-status.js +4 -27
  22. package/dist/app/popup/menu-items-controller.d.ts +2 -0
  23. package/dist/app/popup/menu-items-controller.js +37 -14
  24. package/dist/app/popup/popup-menu-controller.js +30 -5
  25. package/dist/app/rendering/editor-panels.js +20 -9
  26. package/dist/app/rendering/popup-menu-renderer.d.ts +12 -0
  27. package/dist/app/rendering/popup-menu-renderer.js +151 -53
  28. package/dist/app/rendering/render-controller.js +29 -15
  29. package/dist/app/rendering/render-text.js +5 -2
  30. package/dist/app/rendering/status-line-renderer.d.ts +7 -0
  31. package/dist/app/rendering/status-line-renderer.js +191 -94
  32. package/dist/app/rendering/toast-controller.d.ts +1 -0
  33. package/dist/app/rendering/toast-controller.js +17 -0
  34. package/dist/app/screen/mouse-controller.d.ts +1 -0
  35. package/dist/app/screen/mouse-controller.js +17 -20
  36. package/dist/app/screen/scroll-controller.d.ts +1 -0
  37. package/dist/app/screen/scroll-controller.js +6 -0
  38. package/dist/app/screen/status-controller.js +2 -1
  39. package/dist/app/session/request-history.d.ts +4 -0
  40. package/dist/app/session/request-history.js +11 -0
  41. package/dist/app/session/session-search.js +10 -0
  42. package/dist/app/session/tabs-controller.d.ts +4 -4
  43. package/dist/app/session/tabs-controller.js +64 -6
  44. package/dist/app/todo/todo-model.d.ts +2 -2
  45. package/dist/app/todo/todo-model.js +15 -17
  46. package/dist/app/types.d.ts +12 -4
  47. package/dist/config.d.ts +1 -0
  48. package/dist/config.js +10 -1
  49. package/dist/default-pix-config.js +2 -0
  50. package/dist/fuzzy.d.ts +2 -0
  51. package/dist/fuzzy.js +27 -7
  52. package/dist/input-editor.d.ts +9 -0
  53. package/dist/input-editor.js +52 -0
  54. package/dist/schemas/pix-schema.d.ts +1 -0
  55. package/dist/schemas/pix-schema.js +1 -0
  56. package/dist/theme.js +6 -6
  57. package/dist/ui.d.ts +8 -0
  58. package/external/pi-tools-suite/README.md +2 -2
  59. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +40 -5
  60. package/external/pi-tools-suite/src/antigravity-auth/commands.ts +1 -37
  61. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +0 -2
  62. package/external/pi-tools-suite/src/antigravity-auth/index.ts +3 -16
  63. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +33 -17
  64. package/external/pi-tools-suite/src/antigravity-auth/status.ts +1 -1
  65. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +4 -12
  66. package/external/pi-tools-suite/src/antigravity-auth/types.ts +21 -0
  67. package/external/pi-tools-suite/src/config.ts +43 -0
  68. package/external/pi-tools-suite/src/dcp/commands.ts +1 -1
  69. package/external/pi-tools-suite/src/dcp/index.ts +21 -1
  70. package/external/pi-tools-suite/src/dcp/state.ts +225 -3
  71. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +5 -0
  72. package/external/pi-tools-suite/src/index.ts +1 -0
  73. package/external/pi-tools-suite/src/telegram-mirror/README.md +168 -0
  74. package/external/pi-tools-suite/src/telegram-mirror/bot.ts +228 -0
  75. package/external/pi-tools-suite/src/telegram-mirror/events.ts +94 -0
  76. package/external/pi-tools-suite/src/telegram-mirror/format.ts +120 -0
  77. package/external/pi-tools-suite/src/telegram-mirror/index.ts +424 -0
  78. package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +419 -0
  79. package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +408 -0
  80. package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +214 -0
  81. package/external/pi-tools-suite/src/todo/index.ts +3 -64
  82. package/external/pi-tools-suite/src/todo/state/persistence.ts +0 -1
  83. package/external/pi-tools-suite/src/todo/state/state-reducer.ts +7 -37
  84. package/external/pi-tools-suite/src/todo/todo.ts +2 -18
  85. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +2 -11
  86. package/external/pi-tools-suite/src/todo/tool/types.ts +0 -29
  87. package/external/pi-tools-suite/src/todo/view/format.ts +1 -3
  88. package/external/pi-tools-suite/src/tool-descriptions.ts +5 -4
  89. package/external/pi-tools-suite/src/usage/lib/google.ts +50 -30
  90. package/external/pi-tools-suite/src/usage/lib/types.ts +12 -2
  91. package/package.json +14 -3
  92. package/schemas/pix.json +5 -0
@@ -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
+ }
package/dist/app/app.d.ts CHANGED
@@ -44,6 +44,7 @@ export declare class PiUiExtendApp {
44
44
  private readonly extensionShutdownHandler;
45
45
  private runtime;
46
46
  private readonly inputEditor;
47
+ private lastInputEditorContentVersion;
47
48
  private readonly requestHistory;
48
49
  /** Shortcut: get/set the editor text as a plain string. */
49
50
  private get input();
@@ -101,6 +102,7 @@ export declare class PiUiExtendApp {
101
102
  private clearToastTimers;
102
103
  private render;
103
104
  private scheduleRender;
105
+ private syncScrollAfterInputEditorChange;
104
106
  private renderStatusLine;
105
107
  private terminalColumns;
106
108
  private terminalRows;
package/dist/app/app.js CHANGED
@@ -110,6 +110,7 @@ export class PiUiExtendApp {
110
110
  extensionShutdownHandler = () => { };
111
111
  runtime;
112
112
  inputEditor = new InputEditor();
113
+ lastInputEditorContentVersion = this.inputEditor.contentVersion;
113
114
  requestHistory;
114
115
  /** Shortcut: get/set the editor text as a plain string. */
115
116
  get input() { return this.inputEditor.text; }
@@ -126,6 +127,8 @@ export class PiUiExtendApp {
126
127
  constructor(options) {
127
128
  this.options = options;
128
129
  this.theme = THEMES[options.themeName];
130
+ this.pixConfig = loadPixConfig(this.options.cwd);
131
+ setAppIconTheme(this.pixConfig.iconTheme.name);
129
132
  const app = this;
130
133
  this.blinkController = new AppBlinkController({
131
134
  render: () => this.render(),
@@ -157,6 +160,7 @@ export class PiUiExtendApp {
157
160
  });
158
161
  this.tabsController = new AppTabsController({
159
162
  options: this.options,
163
+ maxProjectSessions: this.pixConfig.maxProjectSessions,
160
164
  blinkController: this.blinkController,
161
165
  runtime: () => this.runtime,
162
166
  createRuntimeForNewSession: () => this.createRuntime(newTabRuntimeOptions(this.options)),
@@ -175,8 +179,8 @@ export class PiUiExtendApp {
175
179
  loadSessionHistory: () => this.loadSessionHistory(),
176
180
  loadSessionHistoryAsync: (options) => this.loadSessionHistoryAsync(options),
177
181
  syncUserSessionEntryMetadata: () => this.workspaceActions.syncUserSessionEntryMetadata(),
178
- captureInputState: () => ({ text: this.inputEditor.text, cursor: this.inputEditor.cursor }),
179
- restoreInputState: (state) => this.restoreTabInputState(state.text, state.cursor),
182
+ captureInputState: () => this.inputEditor.draftState,
183
+ restoreInputState: (state) => this.restoreTabInputState(state),
180
184
  closeMenusForTabSwitch: () => this.popupMenus.closeMenusForTabSwitch(),
181
185
  captureDeferredUserMessages: () => this.queuedMessages.captureDeferredUserMessages(),
182
186
  restoreDeferredUserMessages: (messages) => this.queuedMessages.restoreDeferredUserMessages(messages),
@@ -184,8 +188,6 @@ export class PiUiExtendApp {
184
188
  showToast: (message, kind) => this.showToast(message, kind),
185
189
  render: () => this.render(),
186
190
  });
187
- this.pixConfig = loadPixConfig(this.options.cwd);
188
- setAppIconTheme(this.pixConfig.iconTheme.name);
189
191
  this.terminalBellSoundController = new TerminalBellSoundController();
190
192
  this.promptEnhancer = new AppPromptEnhancerController({
191
193
  runtime: () => this.runtime,
@@ -224,10 +226,12 @@ export class PiUiExtendApp {
224
226
  const popupMenuRenderer = new PopupMenuRenderer({
225
227
  theme: this.theme,
226
228
  screenStyler: this.screenStyler,
229
+ modelColors: this.pixConfig.modelColors,
227
230
  get entries() { return app.entries; },
228
231
  get session() { return app.runtime?.session; },
229
232
  get resumeLoading() { return app.resumeLoading; },
230
233
  get resumeSessionCount() { return app.resumeSessions.length; },
234
+ get userMessageJumpLoading() { return app.menuItems.isUserMessageJumpLoading(); },
231
235
  });
232
236
  this.popupMenus = new AppPopupMenuController({
233
237
  get entries() { return app.entries; },
@@ -430,6 +434,7 @@ export class PiUiExtendApp {
430
434
  this.commandController = new AppCommandController({
431
435
  options: this.options,
432
436
  runtime: () => this.runtime,
437
+ requestHistory: () => this.requestHistory,
433
438
  getInput: () => this.input,
434
439
  setInput: (value) => this.setInput(value),
435
440
  promptEnhancerModelRef: () => this.pixConfig.promptEnhancer.modelRef,
@@ -614,6 +619,7 @@ export class PiUiExtendApp {
614
619
  addEntry: (entry) => this.addEntry(entry),
615
620
  addSessionAbortedEntry: () => this.sessionEvents.addSessionAbortedEntry(),
616
621
  showToast: (message, kind) => this.showToast(message, kind),
622
+ dismissActiveDialog: () => this.toastController.dismissActiveDialog(),
617
623
  stopVoiceInput: () => this.voiceController.stopRecording(),
618
624
  isShellCommandRunning: () => this.shellController.isRunning(),
619
625
  runChatShellCommand: (command) => this.shellController.run(command),
@@ -814,10 +820,10 @@ export class PiUiExtendApp {
814
820
  this.popupMenus.resetInputMenuDismissals();
815
821
  this.autocompleteController.dispose();
816
822
  }
817
- restoreTabInputState(text, cursor) {
823
+ restoreTabInputState(state) {
818
824
  this.requestHistory.resetNavigation();
819
825
  this.popupMenus.resetInputMenuDismissals();
820
- this.inputEditor.setText(text, cursor);
826
+ this.inputEditor.setDraftState(state);
821
827
  this.autocompleteController.dispose();
822
828
  }
823
829
  async clearPersistedInputDraft() {
@@ -1007,6 +1013,7 @@ export class PiUiExtendApp {
1007
1013
  this.scheduledRenderTimer = undefined;
1008
1014
  }
1009
1015
  this.autocompleteController.observeInput();
1016
+ this.syncScrollAfterInputEditorChange();
1010
1017
  this.renderController.render();
1011
1018
  }
1012
1019
  scheduleRender() {
@@ -1014,10 +1021,18 @@ export class PiUiExtendApp {
1014
1021
  return;
1015
1022
  this.scheduledRenderTimer = setTimeout(() => {
1016
1023
  this.scheduledRenderTimer = undefined;
1024
+ this.syncScrollAfterInputEditorChange();
1017
1025
  this.renderController.render();
1018
1026
  }, COALESCED_RENDER_DELAY_MS);
1019
1027
  this.scheduledRenderTimer.unref?.();
1020
1028
  }
1029
+ syncScrollAfterInputEditorChange() {
1030
+ const contentVersion = this.inputEditor.contentVersion;
1031
+ if (contentVersion === this.lastInputEditorContentVersion)
1032
+ return;
1033
+ this.lastInputEditorContentVersion = contentVersion;
1034
+ this.scrollController.scrollToBottom();
1035
+ }
1021
1036
  renderStatusLine() {
1022
1037
  this.renderController.renderStatusLine();
1023
1038
  }
@@ -54,6 +54,7 @@ export class AppCommandController {
54
54
  runCloneCommand: () => this.navigationActions.runCloneCommand(),
55
55
  runTreeCommand: (argumentsText) => this.navigationActions.runTreeCommand(argumentsText),
56
56
  runJumpCommand: (argumentsText) => this.navigationActions.runJumpCommand(argumentsText),
57
+ runHistoryCommand: (argumentsText) => this.navigationActions.runHistoryCommand(argumentsText),
57
58
  runSearchCommand: (argumentsText) => this.navigationActions.runSearchCommand(argumentsText),
58
59
  runUnsupportedBuiltinCommand: (commandName, message) => this.navigationActions.runUnsupportedBuiltinCommand(commandName, message),
59
60
  runResumePathCommand: (sessionPath) => this.navigationActions.runResumePathCommand(sessionPath),
@@ -1,11 +1,13 @@
1
1
  import type { AgentSession, AgentSessionRuntime, SessionInfo } from "@earendil-works/pi-coding-agent";
2
2
  import type { SessionSearchResult } from "../session/session-search.js";
3
+ import type { AppRequestHistory } from "../session/request-history.js";
3
4
  import type { ActivePopupMenu, AppOptions, Entry, ModelMenuValue, PixMenuItem, PixMenuOptions, PopupMenuPlacement, ScopedSessionModel, SessionModel, ThinkingMenuValue } from "../types.js";
4
5
  import type { ToastNotifier } from "../../ui.js";
5
6
  export type DirectPopupMenu = Exclude<ActivePopupMenu, "slash">;
6
7
  export type CommandControllerHost = {
7
8
  readonly options: AppOptions;
8
9
  runtime(): AgentSessionRuntime | undefined;
10
+ requestHistory(): AppRequestHistory;
9
11
  getInput(): string;
10
12
  setInput(value: string): void;
11
13
  promptEnhancerModelRef(): string;
@@ -2,6 +2,14 @@ import type { SessionInfo } from "@earendil-works/pi-coding-agent";
2
2
  import type { CommandControllerHost } from "./command-host.js";
3
3
  import { type ResumeSessionLoaderOptions } from "../session/resume-session-loader.js";
4
4
  import type { PopupMenuPlacement } from "../types.js";
5
+ export declare function formatHistoryMenuLabel(text: string): string;
6
+ export declare function historyHighlightRanges(ranges: readonly {
7
+ start: number;
8
+ end: number;
9
+ }[], text: string): {
10
+ start: number;
11
+ end: number;
12
+ }[];
5
13
  export declare class NavigationCommandActions {
6
14
  private readonly host;
7
15
  private readonly resumeSessionLoader;
@@ -11,6 +19,7 @@ export declare class NavigationCommandActions {
11
19
  runCloneCommand(): Promise<void>;
12
20
  runTreeCommand(argumentsText: string): Promise<void>;
13
21
  runJumpCommand(argumentsText: string): Promise<void>;
22
+ runHistoryCommand(argumentsText: string): Promise<void>;
14
23
  runSearchCommand(argumentsText: string): Promise<void>;
15
24
  runUnsupportedBuiltinCommand(commandName: string, message: string): Promise<void>;
16
25
  runResumePathCommand(sessionPath: string): Promise<void>;
@@ -11,6 +11,24 @@ function nextTick() {
11
11
  setImmediate(resolve);
12
12
  });
13
13
  }
14
+ export function formatHistoryMenuLabel(text) {
15
+ return sanitizeText(text).replace(/\n/g, " ↵ ");
16
+ }
17
+ export function historyHighlightRanges(ranges, text) {
18
+ return ranges.map((range) => ({
19
+ start: historyLabelIndex(range.start, text),
20
+ end: historyLabelIndex(range.end, text),
21
+ })).filter((range) => range.end > range.start);
22
+ }
23
+ function historyLabelIndex(index, text) {
24
+ const before = text.slice(0, Math.max(0, Math.min(index, text.length)));
25
+ const newlineCount = before.split("\n").length - 1;
26
+ return before.length + newlineCount * 2;
27
+ }
28
+ function formatHistoryMenuDescription(text) {
29
+ const lines = sanitizeText(text).split("\n");
30
+ return lines.length > 1 ? `${lines.length} lines` : undefined;
31
+ }
14
32
  export class NavigationCommandActions {
15
33
  host;
16
34
  resumeSessionLoader;
@@ -98,12 +116,53 @@ export class NavigationCommandActions {
98
116
  const runtime = getRuntime(this.host, "jump");
99
117
  if (!runtime)
100
118
  return;
101
- this.host.setStatus("scanning session messages…");
102
- this.host.render();
103
- await this.host.refreshUserMessageJumpMenuItems();
104
119
  this.host.openDirectPopupMenu("user-message-jump", { preserveStatus: true });
105
120
  this.host.setDirectPopupMenuQuery(argumentsText.trim());
106
121
  this.host.render();
122
+ try {
123
+ await this.host.refreshUserMessageJumpMenuItems();
124
+ }
125
+ catch (error) {
126
+ this.host.toast.error(`Could not load jump messages: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ finally {
129
+ this.host.render();
130
+ }
131
+ }
132
+ async runHistoryCommand(argumentsText) {
133
+ const query = argumentsText.trim();
134
+ const matches = this.host.requestHistory().searchMatches(query, 100);
135
+ if (matches.length === 0) {
136
+ this.host.addEntry({ id: createId("system"), kind: "system", text: query ? `No command history found for: ${query}` : "Command history is empty." });
137
+ this.host.toast.info(query ? "No matching command history" : "Command history is empty");
138
+ this.host.setSessionStatus(this.host.runtime()?.session);
139
+ this.host.render();
140
+ return;
141
+ }
142
+ const selected = await this.host.showMenu(matches.map((match) => {
143
+ const description = formatHistoryMenuDescription(match.value);
144
+ return {
145
+ value: match.value,
146
+ label: formatHistoryMenuLabel(match.value),
147
+ labelHighlightRanges: match.matchedText === match.label ? historyHighlightRanges(match.matchedRanges, match.value) : [],
148
+ ...(description === undefined ? {} : { description }),
149
+ };
150
+ }), {
151
+ title: query ? `Search command history: ${query}` : "Command history",
152
+ placeholder: "Filter history",
153
+ emptyText: "No matching command history",
154
+ searchable: true,
155
+ minScorePerCharacter: 8,
156
+ preferKeyboardLayoutMatches: true,
157
+ });
158
+ if (!selected) {
159
+ this.host.setSessionStatus(this.host.runtime()?.session);
160
+ return;
161
+ }
162
+ this.host.setInput(selected);
163
+ this.host.toast.info("Restored command from history");
164
+ this.host.setSessionStatus(this.host.runtime()?.session);
165
+ this.host.render();
107
166
  }
108
167
  async runSearchCommand(argumentsText) {
109
168
  const runtime = getIdleRuntime(this.host, "search");
@@ -25,6 +25,7 @@ export type CommandRegistryActions = {
25
25
  runCloneCommand(): Promise<void>;
26
26
  runTreeCommand(argumentsText: string): Promise<void>;
27
27
  runJumpCommand(argumentsText: string): Promise<void>;
28
+ runHistoryCommand(argumentsText: string): Promise<void>;
28
29
  runSearchCommand(argumentsText: string): Promise<void>;
29
30
  runUnsupportedBuiltinCommand(commandName: string, message: string): Promise<void>;
30
31
  runReloadCommand(): Promise<void>;
@@ -185,6 +185,14 @@ export function createSlashCommands(actions, host) {
185
185
  allowArguments: true,
186
186
  run: (argumentsText) => actions.runJumpCommand(argumentsText),
187
187
  },
188
+ {
189
+ name: "history",
190
+ description: "Search command history and restore a match",
191
+ kind: "builtin",
192
+ keywords: ["command", "request", "prompt", "find", "recent"],
193
+ allowArguments: true,
194
+ run: (argumentsText) => actions.runHistoryCommand(argumentsText),
195
+ },
188
196
  {
189
197
  name: "search",
190
198
  description: "Search sessions and open a match in a new tab",
@@ -42,7 +42,6 @@ export declare const GIT_BRANCH_CACHE_MS = 30000;
42
42
  export declare const TODO_TOOL_NAME = "todo";
43
43
  export declare const TODO_ACTIONS: readonly ["create", "update", "batch_create", "batch_update", "list", "get", "delete", "clear", "export", "import"];
44
44
  export declare const TODO_STATUSES: readonly ["pending", "in_progress", "deferred", "completed", "deleted"];
45
- export declare const TODO_PRIORITIES: readonly ["low", "medium", "high", "urgent"];
46
45
  export declare const SUBAGENT_STATUSES: readonly ["planned", "running", "retrying", "done", "failed", "stopped"];
47
46
  export declare const SUBAGENT_ACTIVE_STATUSES: readonly ["planned", "running", "retrying"];
48
47
  export declare const SUBAGENT_TERMINAL_STATUSES: readonly ["done", "failed", "stopped"];
@@ -90,7 +90,6 @@ export const TODO_ACTIONS = [
90
90
  "import",
91
91
  ];
92
92
  export const TODO_STATUSES = ["pending", "in_progress", "deferred", "completed", "deleted"];
93
- export const TODO_PRIORITIES = ["low", "medium", "high", "urgent"];
94
93
  export const SUBAGENT_STATUSES = ["planned", "running", "retrying", "done", "failed", "stopped"];
95
94
  export const SUBAGENT_ACTIVE_STATUSES = ["planned", "running", "retrying"];
96
95
  export const SUBAGENT_TERMINAL_STATUSES = ["done", "failed", "stopped"];
@@ -12,6 +12,7 @@ declare const NERD_FONT_ICONS: {
12
12
  readonly deleted: "󰅙";
13
13
  readonly deferred: "󰍷";
14
14
  readonly info: "󰋼";
15
+ readonly lightbulb: "󰌵";
15
16
  readonly microphone: "󰍬";
16
17
  readonly plus: "󰐕";
17
18
  readonly pause: "󰏤";
package/dist/app/icons.js CHANGED
@@ -18,6 +18,7 @@ const NERD_FONT_ICONS = {
18
18
  deleted: "\u{f0159}",
19
19
  deferred: "\u{f0377}",
20
20
  info: "\u{f02fc}",
21
+ lightbulb: "\u{f0335}",
21
22
  microphone: "\u{f036c}",
22
23
  plus: "\u{f0415}",
23
24
  pause: "\u{f03e4}",
@@ -44,6 +45,7 @@ const FALLBACK_ICONS = {
44
45
  deleted: "×",
45
46
  deferred: "↷",
46
47
  info: "i",
48
+ lightbulb: "💡",
47
49
  microphone: "m",
48
50
  plus: "+",
49
51
  pause: "⏸",
@@ -19,6 +19,7 @@ export type AppInputActionControllerHost = {
19
19
  addEntry(entry: Entry): void;
20
20
  addSessionAbortedEntry(): void;
21
21
  showToast(message: string, kind: "success" | "error" | "warning" | "info"): void;
22
+ dismissActiveDialog?(): boolean;
22
23
  stopVoiceInput(): Promise<void>;
23
24
  isShellCommandRunning(): boolean;
24
25
  runChatShellCommand(command: string): Promise<InteractiveShellCommandResult>;
@@ -80,10 +80,11 @@ export class AppInputActionController {
80
80
  }
81
81
  }
82
82
  closeActiveGlobalUi() {
83
- if (!this.popupMenus.syncActivePopupMenu())
84
- return false;
85
- this.popupMenus.cancelActivePopupMenu();
86
- return true;
83
+ if (this.popupMenus.syncActivePopupMenu()) {
84
+ this.popupMenus.cancelActivePopupMenu();
85
+ return true;
86
+ }
87
+ return this.host.dismissActiveDialog?.() ?? false;
87
88
  }
88
89
  async abortStreamingSession(runtime, options) {
89
90
  const session = runtime.session;
@@ -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() {