pi-ui-extend 0.1.21 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -10
  2. package/bin/pix.mjs +11 -154
  3. package/dist/app/app.d.ts +1 -0
  4. package/dist/app/app.js +34 -9
  5. package/dist/app/cli/startup-info.d.ts +0 -1
  6. package/dist/app/cli/startup-info.js +0 -3
  7. package/dist/app/commands/command-session-actions.js +3 -0
  8. package/dist/app/popup/popup-menu-controller.js +7 -1
  9. package/dist/app/rendering/conversation-entry-renderer.js +29 -40
  10. package/dist/app/rendering/render-text.d.ts +6 -0
  11. package/dist/app/rendering/render-text.js +9 -0
  12. package/dist/app/rendering/tab-line-renderer.js +1 -5
  13. package/dist/app/rendering/tool-block-renderer.js +7 -1
  14. package/dist/app/screen/mouse-controller.js +14 -6
  15. package/dist/app/session/session-event-controller.js +5 -4
  16. package/dist/app/session/session-lifecycle-controller.js +0 -4
  17. package/dist/app/session/tabs-controller.d.ts +5 -1
  18. package/dist/app/session/tabs-controller.js +111 -23
  19. package/dist/app/types.d.ts +5 -0
  20. package/dist/app/workspace/workspace-actions-controller.d.ts +3 -0
  21. package/dist/app/workspace/workspace-actions-controller.js +71 -16
  22. package/dist/app/workspace/workspace-undo.js +41 -6
  23. package/dist/markdown-format.d.ts +4 -0
  24. package/dist/markdown-format.js +6 -1
  25. package/dist/theme.js +18 -18
  26. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +1 -0
  27. package/external/pi-tools-suite/src/telegram-mirror/README.md +81 -46
  28. package/external/pi-tools-suite/src/telegram-mirror/bot.ts +81 -10
  29. package/external/pi-tools-suite/src/telegram-mirror/events.ts +6 -38
  30. package/external/pi-tools-suite/src/telegram-mirror/index.ts +246 -40
  31. package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +20 -0
  32. package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +247 -17
  33. package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +75 -78
  34. package/external/pi-tools-suite/src/todo/index.ts +7 -6
  35. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +1 -1
  36. package/external/pi-tools-suite/src/web-search/index.ts +139 -2
  37. package/package.json +7 -7
package/README.md CHANGED
@@ -87,7 +87,6 @@ Useful flags:
87
87
  - `--cwd <path>`: workspace used for Pi tools, settings, resources, and sessions.
88
88
  - `--no-session`: run with an in-memory SDK session.
89
89
  - `--model <provider/model[:thinking]>`: request a specific model, for example `anthropic/claude-sonnet-4-20250514:medium`.
90
- - `--reload-on-build`: restart the running Pix process after a successful watcher build.
91
90
 
92
91
  ## Updating Pix
93
92
 
@@ -143,20 +142,12 @@ Run Pix against a workspace:
143
142
  pix --cwd /path/to/workspace
144
143
  ```
145
144
 
146
- During UI development, run the watcher in another terminal:
145
+ During UI development, run the watcher in another terminal, then restart Pix after rebuilds when needed:
147
146
 
148
147
  ```bash
149
148
  npm run watch:pix
150
149
  ```
151
150
 
152
- Each running instance can reload after successful builds:
153
-
154
- ```bash
155
- PIX_RELOAD_ON_BUILD=1 pix --cwd /path/to/workspace
156
- # or
157
- pix --reload-on-build --cwd /path/to/workspace
158
- ```
159
-
160
151
  For a one-shot dev launch that rebuilds and refreshes the global link first:
161
152
 
162
153
  ```bash
package/bin/pix.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
4
3
  import { delimiter, dirname, join } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
 
@@ -11,10 +10,7 @@ const packageRoot = dirname(dirname(launcherPath));
11
10
  const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
12
11
  const updatePath = fileURLToPath(new URL("../dist/app/cli/update.js", import.meta.url));
13
12
  const installPath = fileURLToPath(new URL("../dist/app/cli/install.js", import.meta.url));
14
- const distPath = dirname(mainPath);
15
- const rawArgs = process.argv.slice(2);
16
- const childArgs = [];
17
- let reloadOnBuild = truthyEnv(process.env.PIX_RELOAD_ON_BUILD);
13
+ const cliArgs = process.argv.slice(2);
18
14
 
19
15
  if (!isCurrentNodeSupported()) {
20
16
  console.error(`[pix] Node ${minimumNodeVersionLabel}+ is required; current Node is ${process.versions.node}.`);
@@ -22,63 +18,31 @@ if (!isCurrentNodeSupported()) {
22
18
  process.exit(1);
23
19
  }
24
20
 
25
- for (const arg of rawArgs) {
26
- if (arg === "--reload-on-build") {
27
- reloadOnBuild = true;
28
- continue;
29
- }
30
- if (arg === "--no-reload-on-build") {
31
- reloadOnBuild = false;
32
- continue;
33
- }
34
- childArgs.push(arg);
35
- }
21
+ applyPixRuntimeEnv();
36
22
 
37
- if (childArgs[0] === "update") {
23
+ if (cliArgs[0] === "update") {
38
24
  if (!existsSync(updatePath)) {
39
25
  console.error("pix update is not built yet. Run `npm run build:pix` or update from a published package.");
40
26
  process.exit(1);
41
27
  }
42
28
  const { runPixUpdateCli } = await import(new URL("../dist/app/cli/update.js", import.meta.url));
43
- process.exit(await runPixUpdateCli(childArgs.slice(1)));
29
+ process.exit(await runPixUpdateCli(cliArgs.slice(1)));
44
30
  }
45
31
 
46
- if (childArgs[0] === "install" || childArgs[0] === "setup") {
32
+ if (cliArgs[0] === "install" || cliArgs[0] === "setup") {
47
33
  if (!existsSync(installPath)) {
48
34
  console.error("pix install is not built yet. Run `npm run build:pix` or update from a published package.");
49
35
  process.exit(1);
50
36
  }
51
37
  const { runPixInstallCli } = await import(new URL("../dist/app/cli/install.js", import.meta.url));
52
- process.exit(await runPixInstallCli(childArgs.slice(1), { env: pixChildEnv() }));
38
+ process.exit(await runPixInstallCli(cliArgs.slice(1), { env: process.env }));
53
39
  }
54
40
 
55
41
  if (!existsSync(mainPath)) {
56
42
  console.error("pix is not built yet. Run `npm run build:pix` or `npm run watch:pix`.");
57
43
  process.exit(1);
58
44
  }
59
-
60
- let child = undefined;
61
- let reloadTimer = undefined;
62
- let distPollTimer = undefined;
63
- let distSnapshot = snapshotDist();
64
- let restarting = false;
65
- let shuttingDown = false;
66
-
67
- startChild();
68
- if (reloadOnBuild) startDistPolling();
69
-
70
- for (const signal of ["SIGINT", "SIGTERM"]) {
71
- process.on(signal, () => {
72
- shuttingDown = true;
73
- stopDistPolling();
74
- child?.kill(signal);
75
- });
76
- }
77
-
78
- function truthyEnv(value) {
79
- if (!value) return false;
80
- return !["0", "false", "no", "off"].includes(value.toLowerCase());
81
- }
45
+ await import(new URL("../dist/main.js", import.meta.url));
82
46
 
83
47
  function isCurrentNodeSupported() {
84
48
  const parts = process.versions.node.split(".").map((part) => Number.parseInt(part, 10));
@@ -91,117 +55,10 @@ function isCurrentNodeSupported() {
91
55
  return true;
92
56
  }
93
57
 
94
- function startChild() {
95
- child = spawn(process.execPath, [mainPath, ...childArgs], {
96
- stdio: "inherit",
97
- env: pixChildEnv(),
98
- });
99
-
100
- child.on("error", (error) => {
101
- console.error(error.message);
102
- process.exitCode = 1;
103
- });
104
-
105
- child.on("exit", (code, signal) => {
106
- child = undefined;
107
- if (restarting) return;
108
-
109
- shuttingDown = true;
110
- stopDistPolling();
111
- if (signal) {
112
- process.exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1;
113
- return;
114
- }
115
- process.exitCode = code ?? 1;
116
- });
117
- }
118
-
119
- function pixChildEnv() {
120
- const env = { ...process.env };
58
+ function applyPixRuntimeEnv() {
121
59
  const bundledBinPath = join(packageRoot, "node_modules", ".bin");
122
60
  if (existsSync(bundledBinPath)) {
123
- env.PATH = [bundledBinPath, env.PATH ?? ""].filter(Boolean).join(delimiter);
124
- env.PIX_BUNDLED_PI_BIN = bundledBinPath;
61
+ process.env.PATH = [bundledBinPath, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
62
+ process.env.PIX_BUNDLED_PI_BIN = bundledBinPath;
125
63
  }
126
- return env;
127
- }
128
-
129
- function startDistPolling() {
130
- const pollInterval = Number(process.env.PIX_RELOAD_POLL_MS ?? 1000);
131
- distPollTimer = setInterval(() => {
132
- const nextSnapshot = snapshotDist();
133
- if (nextSnapshot === distSnapshot) return;
134
-
135
- distSnapshot = nextSnapshot;
136
- queueReload();
137
- }, Number.isFinite(pollInterval) && pollInterval > 0 ? pollInterval : 1000);
138
- }
139
-
140
- function stopDistPolling() {
141
- if (reloadTimer) clearTimeout(reloadTimer);
142
- if (distPollTimer) clearInterval(distPollTimer);
143
- reloadTimer = undefined;
144
- distPollTimer = undefined;
145
- }
146
-
147
- function queueReload() {
148
- if (shuttingDown) return;
149
- if (reloadTimer) clearTimeout(reloadTimer);
150
- reloadTimer = setTimeout(() => {
151
- reloadTimer = undefined;
152
- restartChild();
153
- }, 250);
154
- }
155
-
156
- function restartChild() {
157
- if (shuttingDown) return;
158
- if (!child) {
159
- startChild();
160
- return;
161
- }
162
-
163
- restarting = true;
164
- const currentChild = child;
165
- currentChild.once("exit", () => {
166
- restarting = false;
167
- if (!shuttingDown) startChild();
168
- });
169
- console.error("[pix] dist changed; restarting renderer");
170
- currentChild.kill("SIGTERM");
171
- }
172
-
173
- function snapshotDist() {
174
- let newestMtime = 0;
175
- let runtimeFileCount = 0;
176
- const pendingDirs = [distPath];
177
-
178
- while (pendingDirs.length > 0) {
179
- const currentDir = pendingDirs.pop();
180
- if (!currentDir) continue;
181
-
182
- let entries;
183
- try {
184
- entries = readdirSync(currentDir, { withFileTypes: true });
185
- } catch {
186
- continue;
187
- }
188
-
189
- for (const entry of entries) {
190
- const entryPath = join(currentDir, entry.name);
191
- if (entry.isDirectory()) {
192
- pendingDirs.push(entryPath);
193
- continue;
194
- }
195
- if (!entry.isFile() || !entry.name.endsWith(".js")) continue;
196
-
197
- runtimeFileCount += 1;
198
- try {
199
- newestMtime = Math.max(newestMtime, statSync(entryPath).mtimeMs);
200
- } catch {
201
- // If tsc is replacing a file while we scan, the next poll will see the final state.
202
- }
203
- }
204
- }
205
-
206
- return `${runtimeFileCount}:${newestMtime}`;
207
64
  }
package/dist/app/app.d.ts CHANGED
@@ -85,6 +85,7 @@ export declare class PiUiExtendApp {
85
85
  private openSearchResultInNewTab;
86
86
  private scrollToUserMessageJumpTarget;
87
87
  private findUserEntryBySessionEntryId;
88
+ private findUserEntryByJumpText;
88
89
  private loadSessionHistoryAsync;
89
90
  private handleSessionEvent;
90
91
  private findEntry;
package/dist/app/app.js CHANGED
@@ -46,6 +46,9 @@ import { AppVoiceController } from "./input/voice-controller.js";
46
46
  import { createIsolatedExtensionEventBus } from "./extensions/extension-event-bus.js";
47
47
  import { setAppIconTheme } from "./icons.js";
48
48
  const TERMINAL_BELL_ATTENTION_EVENT = "pix:terminal-bell:attention";
49
+ function normalizeJumpTargetText(text) {
50
+ return text.replace(/\s+/gu, " ").trim();
51
+ }
49
52
  const SUBAGENTS_LIVE_STATE_EVENT = "pi-tools-suite:async-subagents:live-state";
50
53
  const TODO_STATE_EVENT = "pi-tools-suite:todo:state";
51
54
  const COALESCED_RENDER_DELAY_MS = 16;
@@ -914,20 +917,42 @@ export class PiUiExtendApp {
914
917
  async scrollToUserMessageJumpTarget(target) {
915
918
  if (target.entryId && this.scrollController.scrollToConversationEntry(target.entryId))
916
919
  return true;
917
- if (!target.sessionEntryId)
918
- return false;
919
- let entry = this.findUserEntryBySessionEntryId(target.sessionEntryId);
920
- while (!entry && this.sessionEvents.hasOlderSessionHistory() && !this.sessionEvents.isLoadingOlderSessionHistory()) {
921
- const loaded = await this.sessionEvents.loadOlderSessionHistory({ render: false });
922
- if (!loaded)
923
- break;
924
- entry = this.findUserEntryBySessionEntryId(target.sessionEntryId);
920
+ this.workspaceActions.syncUserSessionEntryMetadata();
921
+ if (target.sessionEntryId) {
922
+ let entry = this.findUserEntryBySessionEntryId(target.sessionEntryId);
923
+ while (!entry && this.sessionEvents.hasOlderSessionHistory() && !this.sessionEvents.isLoadingOlderSessionHistory()) {
924
+ const loaded = await this.sessionEvents.loadOlderSessionHistory({ render: false });
925
+ if (!loaded)
926
+ break;
927
+ entry = this.findUserEntryBySessionEntryId(target.sessionEntryId);
928
+ }
929
+ if (entry && this.scrollController.scrollToConversationEntry(entry.id))
930
+ return true;
925
931
  }
926
- return entry ? this.scrollController.scrollToConversationEntry(entry.id) : false;
932
+ const fallbackEntry = this.findUserEntryByJumpText(target);
933
+ return fallbackEntry ? this.scrollController.scrollToConversationEntry(fallbackEntry.id) : false;
927
934
  }
928
935
  findUserEntryBySessionEntryId(sessionEntryId) {
929
936
  return this.entries.find((entry) => entry.kind === "user" && entry.sessionEntryId === sessionEntryId);
930
937
  }
938
+ findUserEntryByJumpText(target) {
939
+ if (!target.text)
940
+ return undefined;
941
+ const userEntries = this.entries.filter((entry) => entry.kind === "user");
942
+ if (target.userIndex !== undefined && target.userCount !== undefined) {
943
+ const visibleIndex = target.userIndex - (target.userCount - userEntries.length);
944
+ const entry = userEntries[visibleIndex];
945
+ if (entry && normalizeJumpTargetText(entry.text) === normalizeJumpTargetText(target.text))
946
+ return entry;
947
+ }
948
+ const normalizedTargetText = normalizeJumpTargetText(target.text);
949
+ for (let index = userEntries.length - 1; index >= 0; index -= 1) {
950
+ const entry = userEntries[index];
951
+ if (entry && normalizeJumpTargetText(entry.text) === normalizedTargetText)
952
+ return entry;
953
+ }
954
+ return undefined;
955
+ }
931
956
  async loadSessionHistoryAsync(options) {
932
957
  return this.sessionEvents.loadSessionHistoryAsync(options);
933
958
  }
@@ -1,3 +1,2 @@
1
1
  import type { AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
2
2
  export declare function createStartupInfoMessage(runtime: AgentSessionRuntime): string;
3
- export declare function isEmptyStartupSession(runtime: AgentSessionRuntime): boolean;
@@ -12,9 +12,6 @@ export function createStartupInfoMessage(runtime) {
12
12
  ...sections.flatMap(formatSection),
13
13
  ].join("\n").trimEnd();
14
14
  }
15
- export function isEmptyStartupSession(runtime) {
16
- return Array.isArray(runtime.session.messages) && runtime.session.messages.length === 0;
17
- }
18
15
  function startupSections(runtime) {
19
16
  const loader = runtime.session.resourceLoader;
20
17
  const context = loader.getAgentsFiles();
@@ -9,6 +9,7 @@ import { runProcess } from "../process.js";
9
9
  import { copyTextToClipboard } from "../screen/clipboard.js";
10
10
  import { formatAccountUsageReport, queryAccountUsageReport } from "../model/model-usage-status.js";
11
11
  import { checkPixUpdate, formatPixUpdateCheck, parsePixUpdateArgs, pixUpdateUsage } from "../cli/update.js";
12
+ import { createStartupInfoMessage } from "../cli/startup-info.js";
12
13
  export class SessionCommandActions {
13
14
  host;
14
15
  constructor(host) {
@@ -114,6 +115,8 @@ export class SessionCommandActions {
114
115
  return;
115
116
  const stats = runtime.session.getSessionStats();
116
117
  const lines = [
118
+ createStartupInfoMessage(runtime),
119
+ "",
117
120
  "Session info",
118
121
  ...(runtime.session.sessionName ? [`name: ${runtime.session.sessionName}`] : []),
119
122
  `file: ${stats.sessionFile ?? "in-memory"}`,
@@ -810,7 +810,13 @@ export function buildUserMessageJumpItems(entries) {
810
810
  const preview = sanitizeText(entry.text).replace(/\s+/g, " ").trim();
811
811
  const label = `${index + 1}. ${preview || "(empty message)"}`;
812
812
  return {
813
- value: { ...(entry.entryId === undefined ? {} : { entryId: entry.entryId }), ...(entry.sessionEntryId === undefined ? {} : { sessionEntryId: entry.sessionEntryId }) },
813
+ value: {
814
+ ...(entry.entryId === undefined ? {} : { entryId: entry.entryId }),
815
+ ...(entry.sessionEntryId === undefined ? {} : { sessionEntryId: entry.sessionEntryId }),
816
+ text: entry.text,
817
+ userIndex: index,
818
+ userCount: userEntries.length,
819
+ },
814
820
  label,
815
821
  ...(entry.entryId ? {} : { description: "load older history and jump" }),
816
822
  aliases: [entry.sessionEntryId ?? "", entry.entryId ?? ""],
@@ -1,43 +1,20 @@
1
1
  import { applyOutputFilters } from "../../config.js";
2
2
  import { renderMarkdownTextLines } from "../../markdown-format.js";
3
- import { stringDisplayWidth } from "../../terminal-width.js";
4
3
  import { attachImageClickTargets } from "../screen/image-click-targets.js";
5
4
  import { APP_ICONS } from "../icons.js";
6
- import { horizontalPaddingLayout, padHorizontalText, wrapText } from "./render-text.js";
5
+ import { horizontalPaddingLayout, padHorizontalText, wrapTextLines } from "./render-text.js";
7
6
  import { renderConversationShellEntry } from "./conversation-shell-renderer.js";
8
7
  import { renderConversationToolEntry, renderThinkingEntry } from "./conversation-tool-renderer.js";
9
8
  export function renderConversationEntry(entry, width, options) {
10
9
  const { left: userContentLeft, contentWidth: userContentWidth } = horizontalPaddingLayout(width);
11
- const userLine = (text, entryId, syntaxHighlight, segments) => {
12
- const textWidth = stringDisplayWidth(text);
13
- const padding = Math.max(0, width - textWidth);
14
- const paddedText = " ".repeat(padding) + text;
15
- const offset = padding;
16
- return {
17
- text: paddedText,
18
- colorOverride: options.colors.userForeground,
19
- backgroundOverride: options.colors.userMessageBackground,
20
- ...(segments && segments.length > 0
21
- ? {
22
- segments: segments.map((segment) => ({
23
- ...segment,
24
- start: segment.start + offset,
25
- end: segment.end + offset,
26
- foreground: options.colors.userForeground,
27
- })),
28
- }
29
- : {}),
30
- ...(syntaxHighlight === undefined
31
- ? {}
32
- : {
33
- syntaxHighlight: {
34
- ...syntaxHighlight,
35
- start: syntaxHighlight.start + offset,
36
- },
37
- }),
38
- ...(entryId === undefined ? {} : { target: { kind: "user-message", id: entryId } }),
39
- };
40
- };
10
+ const userLine = (text, entryId, syntaxHighlight, segments) => ({
11
+ text: padHorizontalText(text, width),
12
+ colorOverride: options.colors.userForeground,
13
+ backgroundOverride: options.colors.userMessageBackground,
14
+ ...(segments && segments.length > 0 ? { segments: segments.map((segment) => ({ ...segment, start: segment.start + userContentLeft, end: segment.end + userContentLeft })) } : {}),
15
+ ...(syntaxHighlight === undefined ? {} : { syntaxHighlight }),
16
+ ...(entryId === undefined ? {} : { target: { kind: "user-message", id: entryId } }),
17
+ });
41
18
  const queuedLine = (text, entryId, segments) => ({
42
19
  text,
43
20
  colorOverride: options.colors.userForeground,
@@ -45,17 +22,25 @@ export function renderConversationEntry(entry, width, options) {
45
22
  target: { kind: "queue-message", id: entryId },
46
23
  });
47
24
  const userMessageLines = (userEntry) => {
48
- const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => userLine(line.text, userEntry.id, line.syntaxHighlight, line.segments));
25
+ const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
26
+ ...userLine(line.text, userEntry.id, line.syntaxHighlight, line.segments),
27
+ ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
28
+ ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
29
+ }));
49
30
  return attachImageClickTargets(lines, userEntry.id, userEntry.images, { foreground: options.colors.info, underline: true });
50
31
  };
51
32
  const queuedMessageLines = (queuedEntry) => {
52
33
  const icon = queuedEntry.queueSource === "deferred" ? APP_ICONS.pause : APP_ICONS.timerSand;
53
- const contentLines = wrapText(`${icon} ${queuedEntry.text}`, width);
54
- return contentLines.map((text, index) => queuedLine(text, queuedEntry.id, index === 0 ? [{ start: 0, end: icon.length, foreground: options.colors.info }] : undefined));
34
+ const contentLines = wrapTextLines(`${icon} ${queuedEntry.text}`, width);
35
+ return contentLines.map((line, index) => ({
36
+ ...queuedLine(line.text, queuedEntry.id, index === 0 ? [{ start: 0, end: icon.length, foreground: options.colors.info }] : undefined),
37
+ copyText: line.copyText,
38
+ ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
39
+ }));
55
40
  };
56
41
  switch (entry.kind) {
57
42
  case "system":
58
- return wrapText(`system: ${entry.text}`, width).map((text) => ({ text, variant: "muted" }));
43
+ return wrapTextLines(`system: ${entry.text}`, width).map((line) => ({ text: line.text, copyText: line.copyText, ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}), variant: "muted" }));
59
44
  case "user":
60
45
  return userMessageLines(entry);
61
46
  case "queued":
@@ -65,21 +50,23 @@ export function renderConversationEntry(entry, width, options) {
65
50
  case "custom":
66
51
  return renderCustomEntry(entry, width);
67
52
  case "session-aborted":
68
- return wrapText(entry.text, width).map((text) => ({ text, variant: "error" }));
53
+ return wrapTextLines(entry.text, width).map((line) => ({ text: line.text, copyText: line.copyText, ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}), variant: "error" }));
69
54
  case "shell":
70
55
  return renderConversationShellEntry(entry, width, options);
71
56
  case "thinking":
72
57
  return renderThinkingEntry(entry, width, options);
73
58
  case "error":
74
- return wrapText(`error: ${entry.text}`, width).map((text) => ({ text, variant: "error" }));
59
+ return wrapTextLines(`error: ${entry.text}`, width).map((line) => ({ text: line.text, copyText: line.copyText, ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}), variant: "error" }));
75
60
  case "tool":
76
61
  return renderConversationToolEntry(entry, width, options);
77
62
  }
78
63
  }
79
64
  function renderCustomEntry(entry, width) {
80
65
  const label = `[${entry.customType}]`;
81
- return wrapText(`${label}\n${entry.text}`, width).map((text, index) => ({
82
- text,
66
+ return wrapTextLines(`${label}\n${entry.text}`, width).map((line, index) => ({
67
+ text: line.text,
68
+ copyText: line.copyText,
69
+ ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
83
70
  variant: index === 0 ? "accent" : "normal",
84
71
  }));
85
72
  }
@@ -100,6 +87,8 @@ function renderAssistantLines(text, width, options) {
100
87
  const allSegments = headingSegment ? [headingSegment, ...existingSegments] : existingSegments;
101
88
  lines.push({
102
89
  text: padHorizontalText(line.text, width),
90
+ ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
91
+ ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
103
92
  colorOverride: options.colors.assistantForeground,
104
93
  backgroundOverride: options.colors.assistantMessageBackground,
105
94
  ...(allSegments.length > 0 ? { segments: allSegments } : {}),
@@ -1,5 +1,10 @@
1
1
  import type { Theme } from "../../theme.js";
2
2
  import type { ToolStatusEntry } from "../types.js";
3
+ export type WrappedTextLine = {
4
+ text: string;
5
+ copyText: string;
6
+ continuesOnNextLine?: boolean;
7
+ };
3
8
  export declare function sanitizeText(text: string): string;
4
9
  export declare function alertIconPrefixLength(text: string): number | undefined;
5
10
  export declare function normalizePastedTextForDuplicateKey(text: string): string;
@@ -12,6 +17,7 @@ export declare function toolStatusIcon(entry: ToolStatusEntry): string;
12
17
  export declare function toolStatusIconColor(entry: ToolStatusEntry, colors: Theme["colors"]): string;
13
18
  export declare function wrapLine(text: string, width: number): string[];
14
19
  export declare function wrapText(text: string, width: number): string[];
20
+ export declare function wrapTextLines(text: string, width: number): WrappedTextLine[];
15
21
  export declare function padOrTrimPlain(text: string, width: number): string;
16
22
  export declare function horizontalPaddingLayout(width: number): {
17
23
  left: number;
@@ -93,6 +93,15 @@ export function wrapText(text, width) {
93
93
  const lines = sanitizeText(text).split("\n");
94
94
  return lines.flatMap((line) => wrapLine(line, width));
95
95
  }
96
+ export function wrapTextLines(text, width) {
97
+ return sanitizeText(text)
98
+ .split("\n")
99
+ .flatMap((line) => wrapDisplayLine(line, width).map((wrapped, index, wrappedLines) => ({
100
+ text: wrapped,
101
+ copyText: wrapped,
102
+ ...(index < wrappedLines.length - 1 ? { continuesOnNextLine: true } : {}),
103
+ })));
104
+ }
96
105
  export function padOrTrimPlain(text, width) {
97
106
  return padOrTrimDisplay(text, width);
98
107
  }
@@ -3,7 +3,6 @@ import { APP_ICONS } from "../icons.js";
3
3
  import { ellipsizeDisplay } from "./render-text.js";
4
4
  const TAB_SEPARATOR = " │ ";
5
5
  const EMPTY_NEW_TAB_PREFIX = "│ ";
6
- const DEFAULT_SESSION_TITLE_PATTERN = /^session [0-9a-f]{8}$/iu;
7
6
  export const TAB_PANEL_ROWS = 2;
8
7
  export function tabPanelRows(tabLineVisible, terminalRows, tabCount = TAB_PANEL_ROWS) {
9
8
  if (!tabLineVisible)
@@ -158,10 +157,7 @@ export class TabLineRenderer {
158
157
  return this.buttonLayoutFromText(`${prefix}${ellipsizeDisplay(title, titleWidth)}${suffix}`, 0, statusText.length);
159
158
  }
160
159
  displayTitle(tab) {
161
- const title = tab.title.trim();
162
- if (!DEFAULT_SESSION_TITLE_PATTERN.test(title))
163
- return tab.title;
164
- return tab.titlePlaceholder === "loading" ? "Loading…" : "New";
160
+ return tab.title;
165
161
  }
166
162
  buttonLayoutFromText(text, statusStart, statusLength) {
167
163
  const closeStart = Math.max(0, text.lastIndexOf(APP_ICONS.close));
@@ -117,7 +117,13 @@ function renderToolBodyLines(text, width, target, color, style, colors, syntaxHi
117
117
  ? wrapAnsiStyledDisplayLine(ansiLine, bodyWidth)
118
118
  : wrapBodyLine(displayLine, bodyWidth).map((wrapped) => ({ text: wrapped, segments: [] }));
119
119
  for (const [wrapIndex, wrapped] of wrappedLines.entries()) {
120
- const line = { text: ` ${wrapped.text}`, target, colorOverride: color };
120
+ const line = {
121
+ text: ` ${wrapped.text}`,
122
+ copyText: ` ${wrapped.text}`,
123
+ ...(wrapIndex < wrappedLines.length - 1 ? { continuesOnNextLine: true } : {}),
124
+ target,
125
+ colorOverride: color,
126
+ };
121
127
  if (diffStyle) {
122
128
  const segment = { start: 2, end: line.text.length, foreground: diffStyle.foreground };
123
129
  if (diffStyle.bold != null)
@@ -799,17 +799,19 @@ export class AppMouseController {
799
799
  const width = this.conversationArea()?.viewportColumns ?? this.host.terminalColumns();
800
800
  const count = range.end.line - range.start.line + 1;
801
801
  const renderedLines = this.host.conversationViewport().slice(width, range.start.line, count);
802
- const lines = [];
802
+ let copiedText = "";
803
803
  for (let index = 0; index < count; index += 1) {
804
804
  const rendered = renderedLines[index];
805
- const text = rendered?.text ?? "";
805
+ const lineText = rendered?.text ?? "";
806
806
  const line = range.start.line + index;
807
807
  const startColumn = line === range.start.line ? range.start.x : 1;
808
- const endColumn = line === range.end.line ? range.end.x : text.length + 1;
809
- const lineText = sliceByDisplayColumns(text, startColumn, endColumn);
810
- lines.push(lineText.trimEnd());
808
+ const endColumn = line === range.end.line ? range.end.x : lineText.length + 1;
809
+ const selectedLine = selectedConversationLineText(rendered, lineText, startColumn, endColumn);
810
+ copiedText += selectedLine;
811
+ if (!(rendered?.continuesOnNextLine))
812
+ copiedText += "\n";
811
813
  }
812
- return lines.join("\n").replace(/\s+$/u, "");
814
+ return copiedText.replace(/\s+$/u, "");
813
815
  }
814
816
  conversationPointFromMouse(event, clampToViewport) {
815
817
  const area = this.conversationArea();
@@ -930,6 +932,12 @@ export class AppMouseController {
930
932
  selection.moved = true;
931
933
  }
932
934
  }
935
+ function selectedConversationLineText(rendered, text, startColumn, endColumn) {
936
+ const selectsWholeLine = startColumn <= 1 && endColumn >= text.length + 1;
937
+ if (selectsWholeLine && rendered?.copyText !== undefined)
938
+ return rendered.copyText;
939
+ return sliceByDisplayColumns(text, startColumn, endColumn).trimEnd();
940
+ }
933
941
  function orderedConversationSelection(anchor, current) {
934
942
  if (anchor.line < current.line)
935
943
  return { start: anchor, end: current };
@@ -133,6 +133,8 @@ export class AppSessionEventController {
133
133
  case "tool_execution_end":
134
134
  this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
135
135
  this.recordToolWorkspaceMutation(event.toolCallId, event.toolName, event.result.details, event.isError);
136
+ if (this.currentUserEntryId)
137
+ this.host.scheduleUserSessionEntryMetadataSync();
136
138
  this.host.observeSubagentsToolResult(event.toolName, isRecord(event.result) ? event.result.details : undefined);
137
139
  this.host.observeTodoToolResult(event.toolName, isRecord(event.result) ? event.result.details : undefined, event.isError);
138
140
  this.upsertToolEntry(event.toolCallId, {
@@ -274,7 +276,6 @@ export class AppSessionEventController {
274
276
  this.finishCurrentThinkingEntry();
275
277
  this.flushAssistantTextBuffer(true);
276
278
  this.clearCurrentAssistantState();
277
- this.currentUserEntryId = undefined;
278
279
  }
279
280
  }
280
281
  prepareToolWorkspaceMutation(toolCallId, toolName, args) {
@@ -362,7 +363,7 @@ export class AppSessionEventController {
362
363
  }
363
364
  if (!this.assistantTextBuffer)
364
365
  return visibleText;
365
- if (shouldHoldAssistantStreamTail(this.assistantTextBuffer)) {
366
+ if (shouldHoldAssistantStreamTail(this.assistantTextBuffer, this.hasVisibleAssistantText(visibleText))) {
366
367
  if (final)
367
368
  this.assistantTextBuffer = "";
368
369
  return visibleText;
@@ -439,9 +440,9 @@ function shouldDropAssistantStreamLine(line, hasVisibleText) {
439
440
  return true;
440
441
  return isHiddenMarkdownMetadataLine(line);
441
442
  }
442
- function shouldHoldAssistantStreamTail(text) {
443
+ function shouldHoldAssistantStreamTail(text, hasVisibleText) {
443
444
  if (text.trim().length === 0)
444
- return true;
445
+ return !hasVisibleText;
445
446
  return isPotentialDcpMetadataLine(text);
446
447
  }
447
448
  function isHiddenMarkdownMetadataLine(line) {
@@ -1,7 +1,6 @@
1
1
  import { createId } from "../id.js";
2
2
  import { stringifyUnknown } from "../rendering/message-content.js";
3
3
  import { collectStartupAvailabilityIssues } from "../cli/startup-checks.js";
4
- import { createStartupInfoMessage, isEmptyStartupSession } from "../cli/startup-info.js";
5
4
  export class AppSessionLifecycleController {
6
5
  host;
7
6
  unsubscribe;
@@ -37,9 +36,6 @@ export class AppSessionLifecycleController {
37
36
  await this.bindCurrentSession({ awaitExtensions: false });
38
37
  });
39
38
  await this.bindCurrentSession({ awaitExtensions: false });
40
- if (isEmptyStartupSession(runtime)) {
41
- this.host.addEntry({ id: createId("system"), kind: "system", text: createStartupInfoMessage(runtime) });
42
- }
43
39
  if (runtime.modelFallbackMessage) {
44
40
  this.host.addEntry({ id: createId("system"), kind: "system", text: runtime.modelFallbackMessage });
45
41
  }