pi-ui-extend 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -16,8 +16,9 @@ The npm package is currently named `pi-ui-extend` and installs the `pix` CLI.
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Node.js `24.x` (`24.16.0` is pinned for development).
20
- - A terminal with good Unicode support. JetBrainsMono Nerd Font is recommended for the default icon theme.
19
+ - Node.js `>=22.19.0 <25` (`24.16.0` is pinned for development).
20
+ - A terminal with good Unicode support. JetBrainsMono Nerd Font is recommended for the default icon theme; Pix can install it for the current user on macOS, Linux, and Windows.
21
+ - Linux clipboard support: Pix first tries `wl-copy`, `xclip`, `xsel`, or `termux-clipboard-set`, then falls back to its bundled native clipboard package.
21
22
  - Optional for voice input: SoX (`rec`/`sox`), `ffmpeg`, or Linux `arecord`.
22
23
 
23
24
  Development uses `mise` when available. `.node-version` and `.nvmrc` are also provided for other Node version managers.
@@ -32,6 +33,16 @@ npm install -g pi-ui-extend --ignore-scripts
32
33
 
33
34
  The published package contains built JavaScript, the `pix` launcher, renderer extensions, documentation, and the bundled `pi-tools-suite` extension payload. Users do not need to clone the repository or build TypeScript locally.
34
35
 
36
+ After installation, run the setup check when installing on a new machine:
37
+
38
+ ```bash
39
+ pix install
40
+ # or report only:
41
+ pix install --check
42
+ ```
43
+
44
+ `pix install` checks the icon font, `pi` CLI availability, and clipboard helpers. The `pix` launcher also prepends Pix's bundled dependency bin directory to `PATH`, so a package-manager install can use the bundled Pi CLI even when a separate global `pi` command is not present.
45
+
35
46
  On startup, Pix ensures the bundled suite is available at:
36
47
 
37
48
  ```text
@@ -185,7 +196,7 @@ Runtime requirements:
185
196
 
186
197
  - Optional npm package `vosk`. Pix installs or rebuilds it automatically with scripts enabled on first voice start if the native binding is missing.
187
198
  - A local recorder: SoX (`rec`/`sox`) preferred, or `ffmpeg`; Linux also supports `arecord`.
188
- - JetBrainsMono Nerd Font for default app icons. On macOS, Pix checks this at startup and can install the Homebrew cask `font-jetbrains-mono-nerd-font` when it is missing.
199
+ - JetBrainsMono Nerd Font for default app icons. Pix checks this at startup and can install the font for the current user on macOS, Linux, and Windows when it is missing.
189
200
 
190
201
  If your terminal renders missing glyphs, start Pix with `PIX_USE_FALLBACK_ICONS=1` or set `iconTheme` to `fallback` in `~/.config/pi/pix.jsonc`.
191
202
 
package/bin/pix.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, readdirSync, statSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { delimiter, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const minimumNodeVersion = [22, 19, 0];
8
8
  const minimumNodeVersionLabel = "22.19.0";
9
+ const launcherPath = fileURLToPath(import.meta.url);
10
+ const packageRoot = dirname(dirname(launcherPath));
9
11
  const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
10
12
  const updatePath = fileURLToPath(new URL("../dist/app/update.js", import.meta.url));
13
+ const installPath = fileURLToPath(new URL("../dist/app/install.js", import.meta.url));
11
14
  const distPath = dirname(mainPath);
12
15
  const rawArgs = process.argv.slice(2);
13
16
  const childArgs = [];
@@ -40,6 +43,15 @@ if (childArgs[0] === "update") {
40
43
  process.exit(await runPixUpdateCli(childArgs.slice(1)));
41
44
  }
42
45
 
46
+ if (childArgs[0] === "install" || childArgs[0] === "setup") {
47
+ if (!existsSync(installPath)) {
48
+ console.error("pix install is not built yet. Run `npm run build:pix` or update from a published package.");
49
+ process.exit(1);
50
+ }
51
+ const { runPixInstallCli } = await import(new URL("../dist/app/install.js", import.meta.url));
52
+ process.exit(await runPixInstallCli(childArgs.slice(1), { env: pixChildEnv() }));
53
+ }
54
+
43
55
  if (!existsSync(mainPath)) {
44
56
  console.error("pix is not built yet. Run `npm run build:pix` or `npm run watch:pix`.");
45
57
  process.exit(1);
@@ -82,6 +94,7 @@ function isCurrentNodeSupported() {
82
94
  function startChild() {
83
95
  child = spawn(process.execPath, [mainPath, ...childArgs], {
84
96
  stdio: "inherit",
97
+ env: pixChildEnv(),
85
98
  });
86
99
 
87
100
  child.on("error", (error) => {
@@ -103,6 +116,16 @@ function startChild() {
103
116
  });
104
117
  }
105
118
 
119
+ function pixChildEnv() {
120
+ const env = { ...process.env };
121
+ const bundledBinPath = join(packageRoot, "node_modules", ".bin");
122
+ if (existsSync(bundledBinPath)) {
123
+ env.PATH = [bundledBinPath, env.PATH ?? ""].filter(Boolean).join(delimiter);
124
+ env.PIX_BUNDLED_PI_BIN = bundledBinPath;
125
+ }
126
+ return env;
127
+ }
128
+
106
129
  function startDistPolling() {
107
130
  const pollInterval = Number(process.env.PIX_RELOAD_POLL_MS ?? 1000);
108
131
  distPollTimer = setInterval(() => {
package/dist/app/app.js CHANGED
@@ -493,7 +493,7 @@ export class PiUiExtendApp {
493
493
  void this.tabsController.closeTab(tabId);
494
494
  },
495
495
  toastEntry: (toastId) => this.toastController.toast.entry(toastId),
496
- showToast: (message, kind) => this.showToast(message, kind),
496
+ showToast: (message, kind, options) => this.showToast(message, kind, options),
497
497
  dismissToast: (toastId) => this.toastController.dismissToast(toastId),
498
498
  refreshModelUsageStatus: () => this.refreshModelUsageStatusFromClick(),
499
499
  toggleAllThinkingExpanded: () => {
@@ -842,8 +842,8 @@ export class PiUiExtendApp {
842
842
  this.showToast("Failed to refresh model usage limits", "error");
843
843
  });
844
844
  }
845
- showToast(message, kind = "info") {
846
- this.toastController.showToast(message, kind);
845
+ showToast(message, kind = "info", options) {
846
+ this.toastController.showToast(message, kind, options);
847
847
  }
848
848
  clearToastTimers() {
849
849
  this.toastController.clearToastTimers();
package/dist/app/cli.js CHANGED
@@ -3,12 +3,14 @@ import { parseThemeName } from "../theme.js";
3
3
  export function usage() {
4
4
  return `Usage: pix [--cwd <path>] [--no-session] [--session <path>] [--theme dark|light] [--model <provider/model[:thinking]>]
5
5
  pix update [--check] [--force]
6
+ pix install [--check]
6
7
  npm run dev -- [--cwd <path>] [--no-session] [--session <path>] [--theme dark|light] [--model <provider/model[:thinking]>]
7
8
 
8
9
  Examples:
9
10
  pix --cwd ../pi-mono
10
11
  pix --cwd ../pi-mono --theme light --model anthropic/claude-sonnet-4-20250514:medium
11
- pix update --check`;
12
+ pix update --check
13
+ pix install --check`;
12
14
  }
13
15
  export function parseArgs(argv) {
14
16
  let cwd = process.cwd();
@@ -1 +1,3 @@
1
1
  export declare function copyTextToClipboard(text: string): void;
2
+ export declare function clipboardSupportAvailable(env?: NodeJS.ProcessEnv): boolean;
3
+ export declare function clipboardInstallHint(): string;
@@ -1,4 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ const require = createRequire(import.meta.url);
2
4
  export function copyTextToClipboard(text) {
3
5
  const commands = clipboardCommands();
4
6
  for (const [command, args] of commands) {
@@ -6,7 +8,24 @@ export function copyTextToClipboard(text) {
6
8
  if (!result.error && result.status === 0)
7
9
  return;
8
10
  }
9
- throw new Error("No clipboard command found");
11
+ if (copyWithNativeClipboard(text))
12
+ return;
13
+ throw new Error(`No clipboard command found. ${clipboardInstallHint()}`);
14
+ }
15
+ export function clipboardSupportAvailable(env = process.env) {
16
+ if (clipboardCommands().some(([command]) => commandExists(command, env)))
17
+ return true;
18
+ return resolveNativeClipboardEntrypoint() !== undefined;
19
+ }
20
+ export function clipboardInstallHint() {
21
+ if (process.platform === "linux") {
22
+ return "Install wl-clipboard for Wayland or xclip/xsel for X11 (for example: sudo apt install wl-clipboard xclip xsel).";
23
+ }
24
+ if (process.platform === "darwin")
25
+ return "Install pbcopy or check macOS clipboard permissions.";
26
+ if (process.platform === "win32")
27
+ return "Install clip.exe or check Windows clipboard access.";
28
+ return "Install a platform clipboard command.";
10
29
  }
11
30
  function clipboardCommands() {
12
31
  switch (process.platform) {
@@ -19,6 +38,40 @@ function clipboardCommands() {
19
38
  ["wl-copy", []],
20
39
  ["xclip", ["-selection", "clipboard"]],
21
40
  ["xsel", ["--clipboard", "--input"]],
41
+ ["termux-clipboard-set", []],
22
42
  ];
23
43
  }
24
44
  }
45
+ function copyWithNativeClipboard(text) {
46
+ const entrypoint = resolveNativeClipboardEntrypoint();
47
+ if (!entrypoint)
48
+ return false;
49
+ const script = `
50
+ import { createRequire } from "node:module";
51
+ import { readFileSync } from "node:fs";
52
+ const require = createRequire(${JSON.stringify(import.meta.url)});
53
+ const clipboard = require(${JSON.stringify(entrypoint)});
54
+ await clipboard.setText(readFileSync(0, "utf8"));
55
+ `;
56
+ const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
57
+ input: text,
58
+ stdio: ["pipe", "ignore", "ignore"],
59
+ timeout: 3_000,
60
+ });
61
+ return !result.error && result.status === 0;
62
+ }
63
+ function resolveNativeClipboardEntrypoint() {
64
+ try {
65
+ return require.resolve("@mariozechner/clipboard");
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ function commandExists(command, env) {
72
+ const names = process.platform === "win32" ? [command, command.replace(/\.exe$/iu, ".cmd"), command.replace(/\.exe$/iu, ".bat")] : [command];
73
+ return names.some((name) => spawnSync(process.platform === "win32" ? "where" : "sh", process.platform === "win32" ? [name] : ["-lc", `command -v ${shellQuote(name)}`], { env, stdio: "ignore" }).status === 0);
74
+ }
75
+ function shellQuote(value) {
76
+ return `'${value.replaceAll("'", `'\\''`)}'`;
77
+ }
@@ -1,22 +1,39 @@
1
1
  import { normalizeToolName, parseArgsText } from "../tool-renderers/utils.js";
2
+ const NUDGE_TYPES = ["turn", "iteration", "context-soft", "context-strong"];
2
3
  export function formatDcpStatsToast(session) {
3
4
  const stats = collectDcpSessionStats(session);
4
- const parts = [
5
- `context ${formatContextUsage(stats)}`,
6
- `freed ${formatCompactNumber(stats.tokensSaved)} tokens`,
7
- `${stats.runs.toLocaleString()} ${plural(stats.runs, "run")}`,
8
- stats.items > 0 ? `${stats.items.toLocaleString()} ${plural(stats.items, "item")}` : undefined,
9
- stats.summaryTokens > 0 ? `${formatCompactNumber(stats.summaryTokens)} summary` : undefined,
10
- stats.activeBlocks != null && stats.totalBlocks != null ? `blocks ${stats.activeBlocks}/${stats.totalBlocks}` : undefined,
11
- stats.prunedTools > 0 ? `pruned ${stats.prunedTools.toLocaleString()} tools` : undefined,
12
- ].filter((part) => Boolean(part));
13
- return `DCP: ${parts.join(" · ")}`;
5
+ const nudgeStats = collectDcpNudgeStats(session);
6
+ const activeBlocks = stats.activeBlocks ?? 0;
7
+ const totalBlocks = stats.totalBlocks ?? stats.activeBlocks ?? 0;
8
+ const totalNudgeEvents = nudgeStats.emitted + nudgeStats.upgraded;
9
+ const activeAnchors = NUDGE_TYPES.reduce((sum, type) => sum + nudgeStats.activeByType[type], 0);
10
+ const lines = [
11
+ "DCP Session Statistics:",
12
+ ` Tokens saved (estimated): ${fmt(stats.tokensSaved)}`,
13
+ ` Total pruning operations: ${fmt(stats.totalPruneCount)}`,
14
+ ` Compression blocks active: ${activeBlocks} / ${totalBlocks} total`,
15
+ " Manual mode: off",
16
+ "",
17
+ "Nudge telemetry:",
18
+ ` Sent: ${fmt(nudgeStats.emitted)} emitted, ${fmt(nudgeStats.upgraded)} upgraded`,
19
+ ` By type: ${NUDGE_TYPES.map((type) => `${type}=${fmt(nudgeStats.byType[type])}`).join(", ")}`,
20
+ ` Active anchors: ${fmt(activeAnchors)}${activeAnchors > 0 ? ` (${NUDGE_TYPES.map((type) => `${type}=${fmt(nudgeStats.activeByType[type])}`).join(", ")})` : ""}`,
21
+ ` Cleared after compress: ${fmt(nudgeStats.clearedEvents)} time${nudgeStats.clearedEvents === 1 ? "" : "s"} (${fmt(nudgeStats.clearedAnchors)} anchor${nudgeStats.clearedAnchors === 1 ? "" : "s"})`,
22
+ ` Compliance proxy: ${fmt(nudgeStats.clearedEvents)} compress-after-nudge / ${fmt(totalNudgeEvents)} nudge event${totalNudgeEvents === 1 ? "" : "s"} (${pct(nudgeStats.clearedEvents, totalNudgeEvents)})`,
23
+ nudgeStats.last
24
+ ? ` Last nudge: ${nudgeStats.last.type} ${nudgeStats.last.event} at ${formatDate(nudgeStats.last.createdAt)} (${formatContextPercent(nudgeStats.last.contextPercent)})`
25
+ : " Last nudge: none recorded",
26
+ "",
27
+ `Context: ${formatContextUsage(stats)}`,
28
+ ];
29
+ return lines.join("\n");
14
30
  }
15
31
  function collectDcpSessionStats(session) {
16
32
  const usage = session.getContextUsage();
17
33
  const stats = {
18
34
  runs: 0,
19
35
  tokensSaved: 0,
36
+ totalPruneCount: 0,
20
37
  items: 0,
21
38
  summaryTokens: 0,
22
39
  prunedTools: 0,
@@ -24,9 +41,15 @@ function collectDcpSessionStats(session) {
24
41
  ...(usage?.contextWindow != null ? { contextWindow: usage.contextWindow } : {}),
25
42
  ...(usage?.percent != null ? { contextPercent: usage.percent } : {}),
26
43
  };
27
- for (const entry of session.sessionManager.getBranch()) {
44
+ const branch = session.sessionManager.getBranch();
45
+ const latestState = latestCustomEntryData(branch, "dcp-state");
46
+ if (latestState)
47
+ applyDcpStateStats(stats, latestState);
48
+ for (const entry of branch) {
28
49
  if (entry.type !== "message")
29
50
  continue;
51
+ if (latestState)
52
+ continue;
30
53
  const message = entry.message;
31
54
  if (message.role !== "toolResult")
32
55
  continue;
@@ -39,6 +62,7 @@ function collectDcpSessionStats(session) {
39
62
  continue;
40
63
  stats.runs += 1;
41
64
  stats.tokensSaved += numberValue(result.tokensSaved) ?? 0;
65
+ stats.totalPruneCount = Math.max(stats.totalPruneCount, numberValue(result.totalPruneCount) ?? 0);
42
66
  stats.items += numberValue(result.itemCount) ?? sumDefined(numberValue(result.ranges), numberValue(result.messages)) ?? 0;
43
67
  stats.summaryTokens += numberValue(result.totalSummaryTokens) ?? 0;
44
68
  stats.prunedTools += numberValue(result.prunedTools) ?? 0;
@@ -60,6 +84,91 @@ function collectDcpSessionStats(session) {
60
84
  }
61
85
  return stats;
62
86
  }
87
+ function applyDcpStateStats(stats, data) {
88
+ stats.tokensSaved = numberValue(data.tokensSaved) ?? stats.tokensSaved;
89
+ stats.totalPruneCount = numberValue(data.totalPruneCount) ?? stats.totalPruneCount;
90
+ const blocks = Array.isArray(data.compressionBlocks) ? data.compressionBlocks : undefined;
91
+ if (blocks) {
92
+ stats.totalBlocks = blocks.length;
93
+ stats.activeBlocks = blocks.filter((block) => isRecord(block) && block.active !== false).length;
94
+ }
95
+ if (Array.isArray(data.prunedToolIds))
96
+ stats.prunedTools = data.prunedToolIds.length;
97
+ }
98
+ function collectDcpNudgeStats(session) {
99
+ const stats = {
100
+ emitted: 0,
101
+ upgraded: 0,
102
+ clearedEvents: 0,
103
+ clearedAnchors: 0,
104
+ byType: { "turn": 0, "iteration": 0, "context-soft": 0, "context-strong": 0 },
105
+ activeByType: { "turn": 0, "iteration": 0, "context-soft": 0, "context-strong": 0 },
106
+ };
107
+ const branch = session.sessionManager.getBranch();
108
+ const latestState = latestCustomEntryData(branch, "dcp-state");
109
+ if (latestState)
110
+ applyActiveAnchorStats(stats, latestState);
111
+ for (const entry of branch) {
112
+ const data = customEntryData(entry, "dcp-nudge");
113
+ if (!data)
114
+ continue;
115
+ const event = data.event;
116
+ if ((event === "emitted" || event === "upgraded") && isNudgeType(data.type)) {
117
+ if (event === "emitted")
118
+ stats.emitted += 1;
119
+ else
120
+ stats.upgraded += 1;
121
+ stats.byType[data.type] += 1;
122
+ const createdAt = numberValue(data.createdAt);
123
+ const contextPercent = typeof data.contextPercent === "number" || data.contextPercent === null ? data.contextPercent : undefined;
124
+ if (!stats.last || (createdAt ?? 0) >= (stats.last.createdAt ?? 0)) {
125
+ stats.last = {
126
+ type: data.type,
127
+ event,
128
+ ...(createdAt !== undefined ? { createdAt } : {}),
129
+ ...(contextPercent !== undefined ? { contextPercent } : {}),
130
+ };
131
+ }
132
+ }
133
+ else if (event === "cleared") {
134
+ stats.clearedEvents += 1;
135
+ stats.clearedAnchors += Math.max(0, numberValue(data.clearedAnchors) ?? 0);
136
+ }
137
+ }
138
+ return stats;
139
+ }
140
+ function applyActiveAnchorStats(stats, data) {
141
+ stats.activeByType = { "turn": 0, "iteration": 0, "context-soft": 0, "context-strong": 0 };
142
+ const anchors = Array.isArray(data.nudgeAnchors) ? data.nudgeAnchors : [];
143
+ for (const anchor of anchors) {
144
+ if (isRecord(anchor) && isNudgeType(anchor.type))
145
+ stats.activeByType[anchor.type] += 1;
146
+ }
147
+ const last = isRecord(data.lastNudge) ? data.lastNudge : undefined;
148
+ if (last && isNudgeType(last.type) && !stats.last) {
149
+ const contextPercent = numberValue(last.contextPercent);
150
+ const createdAt = numberValue(last.createdAt);
151
+ stats.last = {
152
+ type: last.type,
153
+ event: "emitted",
154
+ ...(createdAt !== undefined ? { createdAt } : {}),
155
+ ...(contextPercent !== undefined ? { contextPercent: contextPercent * 100 } : {}),
156
+ };
157
+ }
158
+ }
159
+ function customEntryData(entry, customType) {
160
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== customType)
161
+ return undefined;
162
+ return isRecord(entry.data) ? entry.data : undefined;
163
+ }
164
+ function latestCustomEntryData(entries, customType) {
165
+ for (let i = entries.length - 1; i >= 0; i--) {
166
+ const data = customEntryData(entries[i], customType);
167
+ if (data)
168
+ return data;
169
+ }
170
+ return undefined;
171
+ }
63
172
  function parseToolResultText(content) {
64
173
  const parsed = parseArgsText(textContent(content));
65
174
  return isRecord(parsed) ? parsed : undefined;
@@ -87,6 +196,29 @@ function formatContextUsage(stats) {
87
196
  return `${percentText} of ${formatCompactNumber(window)}`;
88
197
  return percentText;
89
198
  }
199
+ function isNudgeType(value) {
200
+ return typeof value === "string" && NUDGE_TYPES.includes(value);
201
+ }
202
+ function fmt(n) {
203
+ return Math.round(n).toLocaleString();
204
+ }
205
+ function pct(numerator, denominator) {
206
+ if (denominator <= 0)
207
+ return "n/a";
208
+ return `${((numerator / denominator) * 100).toFixed(1)}%`;
209
+ }
210
+ function formatDate(ts) {
211
+ if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0)
212
+ return "unknown time";
213
+ return new Date(ts).toLocaleString();
214
+ }
215
+ function formatContextPercent(value) {
216
+ if (value === null)
217
+ return "unknown context";
218
+ if (typeof value !== "number" || !Number.isFinite(value))
219
+ return "unknown context";
220
+ return `${value.toFixed(1)}% context`;
221
+ }
90
222
  function numberValue(value) {
91
223
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
92
224
  }
@@ -108,9 +240,6 @@ function formatPercent(value) {
108
240
  function trimDecimal(value) {
109
241
  return value.toFixed(1).replace(/\.0$/, "");
110
242
  }
111
- function plural(count, word) {
112
- return count === 1 ? word : `${word}s`;
113
- }
114
243
  function isRecord(value) {
115
244
  return typeof value === "object" && value !== null && !Array.isArray(value);
116
245
  }
@@ -0,0 +1,10 @@
1
+ export type PixInstallCliOptions = {
2
+ checkOnly: boolean;
3
+ help: boolean;
4
+ };
5
+ export type PixInstallCliContext = {
6
+ env?: NodeJS.ProcessEnv;
7
+ };
8
+ export declare function pixInstallUsage(): string;
9
+ export declare function parsePixInstallArgs(argv: readonly string[]): PixInstallCliOptions;
10
+ export declare function runPixInstallCli(argv?: readonly string[], context?: PixInstallCliContext): Promise<number>;
@@ -0,0 +1,135 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { FONT_FAMILY_NAME, installJetBrainsNerdFont, isJetBrainsNerdFontInstalled, } from "./nerd-font-controller.js";
5
+ import { clipboardInstallHint, clipboardSupportAvailable } from "./clipboard.js";
6
+ export function pixInstallUsage() {
7
+ return `Usage: pix install [--check]
8
+ pix setup [--check]
9
+
10
+ Check and install Pix runtime helpers for this user.
11
+
12
+ What it checks:
13
+ - ${FONT_FAMILY_NAME} icon font for Pix glyphs
14
+ - pi CLI availability, including Pix's bundled Pi dependency
15
+ - Linux clipboard helpers / native clipboard fallback
16
+
17
+ Options:
18
+ --check Only report missing helpers, do not install
19
+ -h, --help Show this help`;
20
+ }
21
+ export function parsePixInstallArgs(argv) {
22
+ let checkOnly = false;
23
+ let help = false;
24
+ for (const arg of argv) {
25
+ if (arg === "--check") {
26
+ checkOnly = true;
27
+ continue;
28
+ }
29
+ if (arg === "--help" || arg === "-h") {
30
+ help = true;
31
+ continue;
32
+ }
33
+ throw new Error(`Unknown pix install argument: ${arg}\n\n${pixInstallUsage()}`);
34
+ }
35
+ return { checkOnly, help };
36
+ }
37
+ export async function runPixInstallCli(argv = process.argv.slice(2), context = {}) {
38
+ let options;
39
+ try {
40
+ options = parsePixInstallArgs(argv);
41
+ }
42
+ catch (error) {
43
+ console.error(error instanceof Error ? error.message : String(error));
44
+ return 1;
45
+ }
46
+ if (options.help) {
47
+ console.log(pixInstallUsage());
48
+ return 0;
49
+ }
50
+ const env = context.env ?? process.env;
51
+ let failures = 0;
52
+ console.log("Pix install checks");
53
+ if (await isJetBrainsNerdFontInstalled()) {
54
+ console.log(`✓ ${FONT_FAMILY_NAME} is installed`);
55
+ }
56
+ else if (options.checkOnly) {
57
+ console.log(`! ${FONT_FAMILY_NAME} is missing`);
58
+ failures += 1;
59
+ }
60
+ else {
61
+ try {
62
+ await installJetBrainsNerdFont();
63
+ console.log(`✓ Installed ${FONT_FAMILY_NAME}`);
64
+ }
65
+ catch (error) {
66
+ console.error(`✗ Failed to install ${FONT_FAMILY_NAME}: ${errorMessage(error)}`);
67
+ failures += 1;
68
+ }
69
+ }
70
+ const piCli = await resolvePiCliStatus(env);
71
+ if (piCli.available) {
72
+ console.log(`✓ pi CLI is available${piCli.detail ? ` (${piCli.detail})` : ""}`);
73
+ }
74
+ else if (options.checkOnly) {
75
+ console.log("! pi CLI is missing");
76
+ failures += 1;
77
+ }
78
+ else {
79
+ try {
80
+ await installPiCli();
81
+ console.log("✓ Installed pi CLI globally");
82
+ }
83
+ catch (error) {
84
+ console.error(`✗ Failed to install pi CLI: ${errorMessage(error)}`);
85
+ console.error(" Pix can still use its bundled SDK, but sub-agent helpers may need `pi` on PATH.");
86
+ failures += 1;
87
+ }
88
+ }
89
+ if (clipboardSupportAvailable(env)) {
90
+ console.log("✓ Clipboard support is available");
91
+ }
92
+ else {
93
+ console.log(`! Clipboard support is missing. ${clipboardInstallHint()}`);
94
+ if (process.platform === "linux")
95
+ failures += 1;
96
+ }
97
+ return failures === 0 ? 0 : 1;
98
+ }
99
+ async function resolvePiCliStatus(env) {
100
+ const bundledBin = env.PIX_BUNDLED_PI_BIN;
101
+ if (bundledBin && (existsSync(join(bundledBin, process.platform === "win32" ? "pi.cmd" : "pi")) || existsSync(join(bundledBin, "pi")))) {
102
+ return { available: true, detail: "bundled with Pix" };
103
+ }
104
+ if (commandExists("pi", env))
105
+ return { available: true, detail: "PATH" };
106
+ return { available: false };
107
+ }
108
+ async function installPiCli() {
109
+ await runRequired("npm", ["install", "-g", "--ignore-scripts", "--min-release-age=0", "@earendil-works/pi-coding-agent"]);
110
+ }
111
+ function commandExists(command, env = process.env) {
112
+ const pathValue = env.PATH ?? "";
113
+ const dirs = pathValue.split(process.platform === "win32" ? ";" : ":").filter(Boolean);
114
+ const names = process.platform === "win32" ? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`] : [command];
115
+ return dirs.some((dir) => names.some((name) => existsSync(join(dir, name))));
116
+ }
117
+ async function runRequired(command, args) {
118
+ await new Promise((resolve, reject) => {
119
+ const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
120
+ let stderr = "";
121
+ child.stderr.on("data", (chunk) => {
122
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-800);
123
+ });
124
+ child.once("error", reject);
125
+ child.once("close", (code) => {
126
+ if (code === 0)
127
+ resolve();
128
+ else
129
+ reject(new Error(stderr.trim() || `${command} exited with code ${code ?? "unknown"}`));
130
+ });
131
+ });
132
+ }
133
+ function errorMessage(error) {
134
+ return error instanceof Error ? error.message : String(error);
135
+ }
@@ -2,7 +2,7 @@ import type { AppCommandController } from "./command-controller.js";
2
2
  import type { ConversationViewport } from "./conversation-viewport.js";
3
3
  import type { EditorLayoutRenderer } from "./editor-layout-renderer.js";
4
4
  import type { ImageContent, InputEditor } from "../input-editor.js";
5
- import type { ToastEntry } from "../ui.js";
5
+ import type { ToastEntry, ToastVariant } from "../ui.js";
6
6
  import type { AppPopupActionController } from "./popup-action-controller.js";
7
7
  import type { AppPopupMenuController } from "./popup-menu-controller.js";
8
8
  import type { AppScrollController } from "./scroll-controller.js";
@@ -50,7 +50,10 @@ export type AppMouseControllerHost = {
50
50
  switchToTab(tabId: string): void;
51
51
  closeTab(tabId: string): void;
52
52
  toastEntry(toastId: number): ToastEntry | undefined;
53
- showToast(message: string, kind: "success" | "error" | "warning" | "info"): void;
53
+ showToast(message: string, kind: "success" | "error" | "warning" | "info", options?: {
54
+ durationMs?: number;
55
+ variant?: ToastVariant;
56
+ }): void;
54
57
  dismissToast(toastId: number): void;
55
58
  refreshModelUsageStatus(): void | Promise<void>;
56
59
  toggleAllThinkingExpanded?(): void;
@@ -87,10 +90,7 @@ export declare class AppMouseController {
87
90
  } | {
88
91
  kind: "queue-message";
89
92
  id: string;
90
- } | {
91
- kind: "toast";
92
- id: number;
93
- } | undefined>;
93
+ } | import("./types.js").ToastLineTarget | undefined>;
94
94
  readonly renderedRowTexts: Map<number, string>;
95
95
  readonly renderedRowBackgrounds: Map<number, string>;
96
96
  readonly renderedImageTargets: Map<number, readonly ImageClickTarget[]>;
@@ -114,6 +114,10 @@ export class AppMouseController {
114
114
  if (event.button !== 0)
115
115
  return;
116
116
  if (target?.kind === "toast") {
117
+ if (!toastTargetContainsEvent(target, event))
118
+ return;
119
+ if (target.action === "body")
120
+ return;
117
121
  if (this.copyErrorToast(target.id)) {
118
122
  this.showClickFlashForEvent(event);
119
123
  return;
@@ -235,6 +239,14 @@ export class AppMouseController {
235
239
  const statusTarget = this.statusTargetAt(event);
236
240
  if (statusTarget)
237
241
  return statusTarget;
242
+ const toastTarget = this.renderedTargets.get(event.y);
243
+ if (toastTarget?.kind === "toast" && toastTargetContainsEvent(toastTarget, event)) {
244
+ return {
245
+ y: event.y,
246
+ startColumn: toastTarget.startColumn ?? event.x,
247
+ endColumn: toastTarget.endColumn ?? event.x + 1,
248
+ };
249
+ }
238
250
  if (this.renderedTargets.has(event.y)) {
239
251
  const bounds = nonBlankLineBounds(this.renderedRowTexts.get(event.y) ?? "", event.x);
240
252
  return { y: event.y, startColumn: bounds.startColumn, endColumn: bounds.endColumn };
@@ -469,7 +481,8 @@ export class AppMouseController {
469
481
  const session = this.host.runtimeSession();
470
482
  if (!session)
471
483
  return false;
472
- this.host.showToast(formatDcpStatsToast(session), "info");
484
+ const message = formatDcpStatsToast(session);
485
+ this.host.showToast(message, "info", { variant: "dialog" });
473
486
  return true;
474
487
  }
475
488
  handleStatusModelUsageClick(event) {
@@ -910,6 +923,11 @@ export function screenSelectionLineText(row, text, startColumn, endColumn, input
910
923
  function sameConversationPoint(left, right) {
911
924
  return !!left && left.line === right.line && left.x === right.x;
912
925
  }
926
+ function toastTargetContainsEvent(target, event) {
927
+ if (target.startColumn === undefined || target.endColumn === undefined)
928
+ return true;
929
+ return event.x >= target.startColumn && event.x < target.endColumn;
930
+ }
913
931
  function displayCellsInColumnRange(text, startColumn, endColumn) {
914
932
  let cells = "";
915
933
  for (let column = startColumn; column < endColumn; column += 1) {
@@ -2,6 +2,9 @@ export type NerdFontInstallHost = {
2
2
  showToast(message: string, kind: "success" | "error" | "warning" | "info"): void;
3
3
  render(): void;
4
4
  };
5
+ export declare const FONT_FAMILY_NAME = "JetBrainsMono Nerd Font Mono";
6
+ export declare const FONT_FILE_NAME = "JetBrainsMonoNerdFontMono-Regular.ttf";
7
+ export declare const FONT_DOWNLOAD_URL = "https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf";
5
8
  export declare class NerdFontController {
6
9
  private readonly host;
7
10
  private ensureStarted;
@@ -9,3 +12,6 @@ export declare class NerdFontController {
9
12
  ensureInstalledOnStartup(): void;
10
13
  private ensureInstalled;
11
14
  }
15
+ export declare function isJetBrainsNerdFontInstalled(): Promise<boolean>;
16
+ export declare function installJetBrainsNerdFont(): Promise<string>;
17
+ export declare function userFontInstallPath(): string;
@@ -1,8 +1,12 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
+ import { dirname, join } from "node:path";
5
6
  const CASK_NAME = "font-jetbrains-mono-nerd-font";
7
+ export const FONT_FAMILY_NAME = "JetBrainsMono Nerd Font Mono";
8
+ export const FONT_FILE_NAME = "JetBrainsMonoNerdFontMono-Regular.ttf";
9
+ export const FONT_DOWNLOAD_URL = "https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf";
6
10
  const FONT_FILE_PATTERN = /(?:JetBrainsMono|JetBrains).*Nerd.*\.(?:ttf|otf)$/iu;
7
11
  export class NerdFontController {
8
12
  host;
@@ -19,17 +23,9 @@ export class NerdFontController {
19
23
  async ensureInstalled() {
20
24
  if (await isJetBrainsNerdFontInstalled())
21
25
  return;
22
- if (process.platform !== "darwin") {
23
- this.host.showToast("Nerd Font is missing; auto-install is only configured for macOS Homebrew", "warning");
24
- return;
25
- }
26
- if (!commandExists("brew")) {
27
- this.host.showToast("Nerd Font is missing; install Homebrew or JetBrainsMono Nerd Font manually", "warning");
28
- return;
29
- }
30
26
  this.host.showToast("Installing JetBrainsMono Nerd Font…", "info");
31
27
  try {
32
- await runBrewInstall();
28
+ await installJetBrainsNerdFont();
33
29
  if (await isJetBrainsNerdFontInstalled()) {
34
30
  this.host.showToast("JetBrainsMono Nerd Font installed", "success");
35
31
  }
@@ -45,18 +41,89 @@ export class NerdFontController {
45
41
  }
46
42
  }
47
43
  }
48
- async function isJetBrainsNerdFontInstalled() {
44
+ export async function isJetBrainsNerdFontInstalled() {
49
45
  if (commandExists("brew") && spawnSync("brew", ["list", "--cask", CASK_NAME], { stdio: "ignore" }).status === 0)
50
46
  return true;
51
- const fontDirs = [join(homedir(), "Library", "Fonts"), "/Library/Fonts", "/System/Library/Fonts"];
47
+ if (process.platform === "linux" && commandExists("fc-match")) {
48
+ const result = spawnSync("fc-match", ["-f", "%{family}", FONT_FAMILY_NAME], { encoding: "utf8" });
49
+ if (result.status === 0 && /JetBrains.*Nerd/iu.test(result.stdout))
50
+ return true;
51
+ }
52
+ const fontDirs = platformFontDirs();
52
53
  for (const dir of fontDirs) {
54
+ if (await directoryContainsFont(dir))
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ export async function installJetBrainsNerdFont() {
60
+ if (process.platform === "darwin" && commandExists("brew")) {
61
+ await runBrewInstall();
62
+ return CASK_NAME;
63
+ }
64
+ const targetPath = userFontInstallPath();
65
+ await mkdir(dirname(targetPath), { recursive: true });
66
+ const response = await fetch(FONT_DOWNLOAD_URL, {
67
+ headers: { "User-Agent": "pix-font-installer" },
68
+ signal: AbortSignal.timeout(30_000),
69
+ });
70
+ if (!response.ok)
71
+ throw new Error(`download failed with HTTP ${response.status}`);
72
+ const bytes = new Uint8Array(await response.arrayBuffer());
73
+ if (bytes.length < 100_000)
74
+ throw new Error("downloaded font is unexpectedly small");
75
+ await writeFile(targetPath, bytes);
76
+ if (process.platform === "linux")
77
+ runOptionalCommand("fc-cache", ["-f", dirname(targetPath)]);
78
+ if (process.platform === "win32")
79
+ registerWindowsUserFont(targetPath);
80
+ return targetPath;
81
+ }
82
+ export function userFontInstallPath() {
83
+ switch (process.platform) {
84
+ case "darwin":
85
+ return join(homedir(), "Library", "Fonts", FONT_FILE_NAME);
86
+ case "win32":
87
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Microsoft", "Windows", "Fonts", FONT_FILE_NAME);
88
+ default:
89
+ return join(homedir(), ".local", "share", "fonts", "pix", FONT_FILE_NAME);
90
+ }
91
+ }
92
+ function platformFontDirs() {
93
+ switch (process.platform) {
94
+ case "darwin":
95
+ return [join(homedir(), "Library", "Fonts"), "/Library/Fonts", "/System/Library/Fonts"];
96
+ case "win32":
97
+ return [
98
+ join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Microsoft", "Windows", "Fonts"),
99
+ join(process.env.WINDIR ?? "C:\\Windows", "Fonts"),
100
+ ];
101
+ default:
102
+ return [join(homedir(), ".local", "share", "fonts"), join(homedir(), ".fonts"), "/usr/local/share/fonts", "/usr/share/fonts"];
103
+ }
104
+ }
105
+ async function directoryContainsFont(root) {
106
+ if (!existsSync(root))
107
+ return false;
108
+ const pending = [{ dir: root, depth: 0 }];
109
+ let scanned = 0;
110
+ while (pending.length > 0 && scanned < 5_000) {
111
+ const current = pending.pop();
112
+ if (!current)
113
+ continue;
114
+ let entries;
53
115
  try {
54
- const files = await readdir(dir);
55
- if (files.some((file) => FONT_FILE_PATTERN.test(file)))
56
- return true;
116
+ entries = await readdir(current.dir, { withFileTypes: true });
57
117
  }
58
118
  catch {
59
- // Ignore unreadable/missing font directories.
119
+ continue;
120
+ }
121
+ for (const entry of entries) {
122
+ scanned += 1;
123
+ if (entry.isFile() && FONT_FILE_PATTERN.test(entry.name))
124
+ return true;
125
+ if (entry.isDirectory() && current.depth < 4)
126
+ pending.push({ dir: join(current.dir, entry.name), depth: current.depth + 1 });
60
127
  }
61
128
  }
62
129
  return false;
@@ -80,6 +147,20 @@ async function runBrewInstall() {
80
147
  });
81
148
  });
82
149
  }
150
+ function registerWindowsUserFont(fontPath) {
151
+ const escapedPath = fontPath.replaceAll("'", "''");
152
+ const escapedName = `${FONT_FAMILY_NAME} (TrueType)`.replaceAll("'", "''");
153
+ runOptionalCommand("powershell.exe", [
154
+ "-NoProfile",
155
+ "-ExecutionPolicy",
156
+ "Bypass",
157
+ "-Command",
158
+ `New-Item -Path 'HKCU:\\Software\\Microsoft\\Windows NT\\CurrentVersion\\Fonts' -Force | Out-Null; New-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows NT\\CurrentVersion\\Fonts' -Name '${escapedName}' -Value '${escapedPath}' -PropertyType String -Force | Out-Null`,
159
+ ]);
160
+ }
161
+ function runOptionalCommand(command, args) {
162
+ spawnSync(command, args, { stdio: "ignore" });
163
+ }
83
164
  function commandExists(command) {
84
165
  if (process.platform === "win32")
85
166
  return spawnSync("where", [command], { stdio: "ignore" }).status === 0;
@@ -243,10 +243,11 @@ export class AppRenderController {
243
243
  }
244
244
  for (const toastOverlay of renderToastOverlays(this.deps.toastController.toast.visibleStates, columns, Math.max(0, statusRow - topReservedRows - 1), this.deps.theme)) {
245
245
  const row = topReservedRows + toastOverlay.row;
246
- this.deps.mouseController.renderedTargets.set(row, { kind: "toast", id: toastOverlay.id });
247
- this.deps.mouseController.renderedRowTexts.set(row, toastOverlay.text);
248
- setRenderedBackground(row, this.deps.theme.colors.toastBackground);
249
- appendFrameOutput(regionForOverlayRow(row), row, this.renderFrameRow(row, toastOverlay.output));
246
+ const rowText = this.deps.mouseController.renderedRowTexts.get(row) ?? "";
247
+ if (toastOverlay.target)
248
+ this.deps.mouseController.renderedTargets.set(row, toastOverlay.target);
249
+ this.deps.mouseController.renderedRowTexts.set(row, overlayText(rowText, toastOverlay.column, toastOverlay.text));
250
+ appendFrameOutput(regionForOverlayRow(row), row, `\x1b[${row};${toastOverlay.column}H${toastOverlay.output}`);
250
251
  }
251
252
  if (topReservedRows === 0) {
252
253
  const newTabTarget = tabLayout.targets.find((target) => target.kind === "new-tab");
@@ -14,7 +14,7 @@ export async function checkPiCliAvailability(pathValue = process.env.PATH ?? "")
14
14
  return [];
15
15
  return [{
16
16
  kind: "error",
17
- message: "pi CLI is not available on PATH. Install pi or add it to PATH before starting pix.",
17
+ message: "pi CLI is not available on PATH. Run `pix install` or add pi to PATH before starting pix.",
18
18
  }];
19
19
  }
20
20
  export function checkPiToolsSuiteExtensionAvailability(extensionsResult) {
@@ -34,13 +34,16 @@ export function checkPiToolsSuiteExtensionAvailability(extensionsResult) {
34
34
  }
35
35
  async function executableExistsOnPath(command, pathValue) {
36
36
  const dirs = pathValue.split(delimiter).filter((part) => part.length > 0);
37
+ const names = process.platform === "win32" ? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`] : [command];
37
38
  for (const dir of dirs) {
38
- try {
39
- await access(join(dir, command), fsConstants.X_OK);
40
- return true;
41
- }
42
- catch {
43
- // Keep scanning PATH entries.
39
+ for (const name of names) {
40
+ try {
41
+ await access(join(dir, name), fsConstants.X_OK);
42
+ return true;
43
+ }
44
+ catch {
45
+ // Keep scanning PATH entries.
46
+ }
44
47
  }
45
48
  }
46
49
  return false;
@@ -1,4 +1,4 @@
1
- import { Toast, type ToastKind } from "../ui.js";
1
+ import { Toast, type ToastKind, type ToastVariant } from "../ui.js";
2
2
  export type AppToastControllerHost = {
3
3
  render(): void;
4
4
  };
@@ -7,7 +7,10 @@ export declare class AppToastController {
7
7
  readonly toast: Toast;
8
8
  private readonly timers;
9
9
  constructor(host: AppToastControllerHost);
10
- showToast(message: string, kind?: ToastKind): void;
10
+ showToast(message: string, kind?: ToastKind, options?: {
11
+ durationMs?: number;
12
+ variant?: ToastVariant;
13
+ }): void;
11
14
  dismissToast(toastId: number): void;
12
15
  clearToastTimers(): void;
13
16
  }
@@ -7,17 +7,20 @@ export class AppToastController {
7
7
  constructor(host) {
8
8
  this.host = host;
9
9
  }
10
- showToast(message, kind = "info") {
11
- const toastId = this.toast.show(message, kind);
12
- if (kind === "error") {
10
+ showToast(message, kind = "info", options = {}) {
11
+ const toastId = this.toast.show(message, kind, options.variant ? { variant: options.variant } : {});
12
+ if (kind === "error" || options.variant === "dialog") {
13
13
  this.host.render();
14
14
  return;
15
15
  }
16
+ const durationMs = typeof options.durationMs === "number" && Number.isFinite(options.durationMs) && options.durationMs > 0
17
+ ? Math.floor(options.durationMs)
18
+ : TOAST_DURATION_MS;
16
19
  const timer = setTimeout(() => {
17
20
  this.toast.hide(toastId);
18
21
  this.timers.delete(toastId);
19
22
  this.host.render();
20
- }, TOAST_DURATION_MS);
23
+ }, durationMs);
21
24
  this.timers.set(toastId, timer);
22
25
  timer.unref();
23
26
  this.host.render();
@@ -1,9 +1,12 @@
1
1
  import { type Theme } from "../theme.js";
2
2
  import type { ToastEntry } from "../ui.js";
3
+ import type { ToastLineTarget } from "./types.js";
3
4
  export type ToastOverlay = {
4
5
  id: number;
5
6
  row: number;
7
+ column: number;
6
8
  text: string;
7
9
  output: string;
10
+ target?: ToastLineTarget;
8
11
  };
9
12
  export declare function renderToastOverlays(states: readonly ToastEntry[], width: number, maxRows: number, theme: Theme): ToastOverlay[];
@@ -9,6 +9,10 @@ export function renderToastOverlays(states, width, maxRows, theme) {
9
9
  for (const state of [...states].reverse()) {
10
10
  if (overlays.length >= maxRows)
11
11
  break;
12
+ if (state.variant === "dialog") {
13
+ overlays.push(...renderDialogToastOverlay(state, width, Math.max(0, maxRows - overlays.length), theme, overlays.length));
14
+ continue;
15
+ }
12
16
  const icon = toastKindIcon(state.kind);
13
17
  const lines = toastMessageLines(state.message, icon, Math.max(1, width - 6));
14
18
  const visibleLines = lines.slice(0, Math.max(0, maxRows - overlays.length));
@@ -17,19 +21,22 @@ export function renderToastOverlays(states, width, maxRows, theme) {
17
21
  const contentWidth = Math.max(...visibleLines.map((line) => stringDisplayWidth(line)));
18
22
  const toastWidth = Math.min(Math.max(12, contentWidth + 2), Math.max(1, width - 4));
19
23
  const leftWidth = Math.max(0, width - toastWidth - 2);
20
- const rightWidth = Math.max(0, width - leftWidth - toastWidth);
24
+ const column = leftWidth + 1;
21
25
  for (const line of visibleLines) {
22
26
  const message = ` ${padOrTrimPlain(line, Math.max(0, toastWidth - 2))} `;
23
- const text = `${" ".repeat(leftWidth)}${padOrTrimPlain(message, toastWidth)}${" ".repeat(rightWidth)}`;
24
- const output = [
25
- colorLine("", leftWidth, { background: theme.colors.background }),
26
- colorLine(message, toastWidth, {
27
- ...toastKindStyle(state.kind, theme),
28
- bold: true,
29
- }),
30
- colorLine("", rightWidth, { background: theme.colors.background }),
31
- ].join("");
32
- overlays.push({ id: state.id, row: overlays.length + 1, text, output });
27
+ const text = padOrTrimPlain(message, toastWidth);
28
+ const output = colorLine(message, toastWidth, {
29
+ ...toastKindStyle(state.kind, theme),
30
+ bold: true,
31
+ });
32
+ overlays.push({
33
+ id: state.id,
34
+ row: overlays.length + 1,
35
+ column,
36
+ text,
37
+ output,
38
+ target: { kind: "toast", id: state.id, action: "toast", startColumn: column, endColumn: column + toastWidth },
39
+ });
33
40
  }
34
41
  }
35
42
  return overlays;
@@ -53,6 +60,60 @@ function toastMessageLines(message, icon, maxWidth) {
53
60
  }
54
61
  return lines.length > 0 ? lines : [firstPrefix.trimEnd()];
55
62
  }
63
+ function renderDialogToastOverlay(state, width, maxRows, theme, rowOffset) {
64
+ if (maxRows <= 0 || width <= 0)
65
+ return [];
66
+ const maxDialogWidth = Math.max(1, Math.min(width - 4, 72));
67
+ const icon = toastKindIcon(state.kind);
68
+ const closeLabel = `[${APP_ICONS.close}]`;
69
+ const wrappedLines = dialogMessageLines(state.message, Math.max(1, maxDialogWidth - 4));
70
+ const title = `${icon} Dialog`;
71
+ const requiredWidth = Math.max(16, stringDisplayWidth(` ${title} ${closeLabel} `) + 2, ...wrappedLines.map((line) => stringDisplayWidth(line) + 4));
72
+ const dialogWidth = Math.min(maxDialogWidth, Math.max(16, requiredWidth));
73
+ const bodyWidth = Math.max(1, dialogWidth - 4);
74
+ const bodyLines = dialogMessageLines(state.message, bodyWidth);
75
+ const bodyRows = Math.max(0, maxRows - 2);
76
+ const visibleBodyLines = bodyLines.slice(0, bodyRows);
77
+ const includeBottom = maxRows > 1;
78
+ const dialogRows = [
79
+ dialogTopLine(title, closeLabel, dialogWidth),
80
+ ...visibleBodyLines.map((line) => `│ ${padOrTrimPlain(line, bodyWidth)} │`),
81
+ ...(includeBottom ? [`╰${"─".repeat(Math.max(0, dialogWidth - 2))}╯`] : []),
82
+ ].slice(0, maxRows);
83
+ const leftWidth = Math.max(0, width - dialogWidth - 2);
84
+ const column = leftWidth + 1;
85
+ const style = toastKindStyle(state.kind, theme);
86
+ const closeStartColumn = column + 1 + dialogTopCloseOffset(title, closeLabel, dialogWidth);
87
+ const closeEndColumn = closeStartColumn + stringDisplayWidth(closeLabel);
88
+ return dialogRows.map((text, index) => ({
89
+ id: state.id,
90
+ row: rowOffset + index + 1,
91
+ column,
92
+ text,
93
+ output: colorLine(text, dialogWidth, { ...style, bold: true }),
94
+ target: index === 0
95
+ ? { kind: "toast", id: state.id, action: "close", startColumn: closeStartColumn, endColumn: closeEndColumn }
96
+ : { kind: "toast", id: state.id, action: "body", startColumn: column, endColumn: column + dialogWidth },
97
+ }));
98
+ }
99
+ function dialogMessageLines(message, maxWidth) {
100
+ const safeMaxWidth = Math.max(1, maxWidth);
101
+ const lines = sanitizeText(message).split("\n").flatMap((line) => wrapDisplayLine(line, safeMaxWidth));
102
+ return lines.length > 0 ? lines : [""];
103
+ }
104
+ function dialogTopLine(title, closeLabel, width) {
105
+ const innerWidth = Math.max(0, width - 2);
106
+ const closeOffset = dialogTopCloseOffset(title, closeLabel, width);
107
+ const leftLabel = ` ${title} `;
108
+ const spacer = " ".repeat(Math.max(0, closeOffset - stringDisplayWidth(leftLabel)));
109
+ return `╭${padOrTrimPlain(`${leftLabel}${spacer}${closeLabel} `, innerWidth)}╮`;
110
+ }
111
+ function dialogTopCloseOffset(title, closeLabel, width) {
112
+ const innerWidth = Math.max(0, width - 2);
113
+ const leftLabel = ` ${title} `;
114
+ const closeWidth = stringDisplayWidth(closeLabel);
115
+ return Math.max(stringDisplayWidth(leftLabel), innerWidth - closeWidth - 1);
116
+ }
56
117
  function toastKindIcon(kind) {
57
118
  switch (kind) {
58
119
  case "success":
@@ -227,10 +227,14 @@ export type RenderedLine = {
227
227
  } | {
228
228
  kind: "queue-message";
229
229
  id: string;
230
- } | {
231
- kind: "toast";
232
- id: number;
233
- };
230
+ } | ToastLineTarget;
231
+ };
232
+ export type ToastLineTarget = {
233
+ kind: "toast";
234
+ id: number;
235
+ action?: "toast" | "body" | "close";
236
+ startColumn?: number;
237
+ endColumn?: number;
234
238
  };
235
239
  export type ImageClickTarget = {
236
240
  start: number;
package/dist/ui.d.ts CHANGED
@@ -28,9 +28,11 @@ export declare class PopupMenu<T> {
28
28
  }
29
29
  export declare const TOAST_KINDS: readonly ["success", "error", "warning", "info"];
30
30
  export type ToastKind = (typeof TOAST_KINDS)[number];
31
+ export type ToastVariant = "compact" | "dialog";
31
32
  export type ToastState = {
32
33
  message: string;
33
34
  kind: ToastKind;
35
+ variant?: ToastVariant;
34
36
  };
35
37
  export type ToastEntry = ToastState & {
36
38
  id: number;
@@ -47,7 +49,9 @@ export declare function isToastKind(value: unknown): value is ToastKind;
47
49
  export declare class Toast {
48
50
  private readonly entries;
49
51
  private nextId;
50
- show(message: string, kind?: ToastKind): number;
52
+ show(message: string, kind?: ToastKind, options?: {
53
+ variant?: ToastVariant;
54
+ }): number;
51
55
  hide(id?: number): void;
52
56
  get state(): ToastState | undefined;
53
57
  get visibleStates(): readonly ToastEntry[];
package/dist/ui.js CHANGED
@@ -60,10 +60,10 @@ export function isToastKind(value) {
60
60
  export class Toast {
61
61
  entries = [];
62
62
  nextId = 1;
63
- show(message, kind = "info") {
63
+ show(message, kind = "info", options = {}) {
64
64
  const id = this.nextId;
65
65
  this.nextId += 1;
66
- this.entries.push({ id, message, kind, createdAt: Date.now() });
66
+ this.entries.push({ id, message, kind, createdAt: Date.now(), ...(options.variant ? { variant: options.variant } : {}) });
67
67
  return id;
68
68
  }
69
69
  hide(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {