pi-stereo-view 0.1.3 → 0.1.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 - 2026-10-23
4
+
5
+ ### Added
6
+
7
+ - Copy individual user messages, assistant replies and thinking blocks as original text/Markdown. Tool copies include their arguments, full saved results and linked late output, even when collapsed or off screen.
8
+ - Use `/stereo-view copy` to choose and copy a block from the active panel with the keyboard.
9
+ - Hide copy icons and their rows with **Show copy buttons** without disabling keyboard copying. The setting survives restart.
10
+
11
+ ### Changed
12
+
13
+ - Show timestamps above blocks on the left and dim copy icons below on the right, without reserving a side column beside the text.
14
+
15
+ ## 0.1.4 - 2026-10-20
16
+
17
+ ### Fixed
18
+
19
+ - Keep the split layout active while Pi-Bolt streams thinking or text. Later replies no longer require a restart to restore the panels; saved panel sizes and layout settings stay intact.
20
+ - Hide empty self-rendered tool blocks without leaving blank spacing or timestamps, even when the tool returns images to the model. Normal tool output and visibly rendered images remain visible.
21
+
22
+ ### Changed
23
+
24
+ - Show late output linked to a tool call directly below that call in Activity, even when other work has happened since. Activity search follows the same grouping; combined history keeps its original order.
25
+
3
26
  ## 0.1.3 - 2026-10-19
4
27
 
5
28
  - Show the saved split layout from the first visible frame, without briefly displaying the normal layout first.
package/README.md CHANGED
@@ -25,8 +25,18 @@ The chat becomes two zones: your conversation with the agent, and the agent's wo
25
25
 
26
26
  Unrecognized content stays in Conversation unless its metadata links it to a known tool call.
27
27
 
28
+ Late output linked to a tool call appears directly below that call in Activity, even if other work has happened since. Combined history keeps its original order.
29
+
28
30
  Messages and tools keep their familiar appearance. The input stays full width below both panels.
29
31
 
32
+ ## Features
33
+
34
+ - Arrange Conversation and Activity in columns or rows, swap or resize the panels, and zoom either one while keeping the input full width.
35
+ - Copy an individual user message, assistant reply or thinking block with its own copy icon.
36
+ - Copy a complete tool call with its arguments, saved result and linked late output, including collapsed and off-screen text.
37
+ - Search panel history with literal text, Boolean expressions or regular expressions, then jump between matches.
38
+ - Show or hide timestamps and copy icons independently. Layout and display settings survive restart.
39
+
30
40
  ## See it in action
31
41
 
32
42
  ![Switching layouts, swapping panels and resizing history](https://raw.githubusercontent.com/alexshpunt/pi-stereo-view/main/assets/demo.gif)
@@ -67,7 +77,7 @@ Click the toolbar and the buttons in each panel's header:
67
77
  | `[ ▤ ]` / `[ ▥ ]` | Switch between rows and columns. |
68
78
  | `[ ↕ ]` / `[ ↔ ]` | Swap the panels. |
69
79
  | `[ ↗ ]` / `[ ↙ ]` | Zoom a panel / restore the split. |
70
- | `[ ≡ ]` | Open settings to show or hide timestamps. |
80
+ | `[ ≡ ]` | Open timestamp, copy-button and Conversation height settings. |
71
81
  | `[ ⌕ ]` | Search this panel. |
72
82
 
73
83
  **Resize:** drag the double line between the panels — `═` in rows, `║` in columns.
@@ -103,9 +113,21 @@ A match is revealed in its own panel without moving the other one. If matching t
103
113
 
104
114
  Press **Escape** or `[×]` to close search and restore your previous scroll position. Your message draft stays unchanged. Close other dialogs before searching. Search is available in split fullscreen history; combined history and regular mode keep Pi's usual keyboard behaviour.
105
115
 
116
+ ## Copy a block
117
+
118
+ Click the dim `⧉` icon below the right corner of a message, thinking, tool or other history block to copy its text. Copy buttons work in split and zoomed panels, even with timestamps hidden.
119
+
120
+ Messages and thinking keep their original text/Markdown, without terminal colours, clocks or copy buttons. Copying a tool includes its name, arguments, saved result and linked late output. A late-output block also has its own button to copy just that block. Collapsed and off-screen text is included.
121
+
122
+ During streaming, each click copies the content available at that moment. Copy uses only text already available in Pi; it does not fetch omitted output or copy binary attachments. Custom blocks without source text fall back to plain rendered text.
123
+
124
+ For keyboard access, run `/stereo-view copy` and choose a numbered block from the active panel. It copies the same text as that block's button.
125
+
126
+ Use **Show copy buttons** in `≡` or `/stereo-view settings` to hide the icons and their rows. This choice survives restart. Keyboard copying stays available when the icons are hidden.
127
+
106
128
  ## Timestamps
107
129
 
108
- Conversation and Activity can show a dim local `HH:mm:ss` clock beside messages and tool calls. New tool calls show their start time.
130
+ Conversation and Activity can show a dim local `HH:mm:ss` clock above each block on the left, with a small inset. The copy icon sits below the block on the right, so neither control takes width away from the text. New tool calls show their start time.
109
131
 
110
132
  Older tool calls may show `~HH:mm:ss` for an approximate time. `--:--:--` means the time is unknown.
111
133
 
@@ -125,7 +147,8 @@ The same controls are available without the mouse:
125
147
  | Scroll a panel | `/stereo-view scroll chat -10` / `/stereo-view scroll activity -10` |
126
148
  | Zoom / restore Conversation | `/stereo-view zoom chat` |
127
149
  | Zoom / restore Activity | `/stereo-view zoom activity` |
128
- | Timestamp settings | `/stereo-view settings` |
150
+ | Copy a block from the active panel | `/stereo-view copy` |
151
+ | Display settings | `/stereo-view settings` |
129
152
  | Button legend | `/stereo-view help` |
130
153
 
131
154
  `Ctrl+Alt+T` toggles split, `Ctrl+Alt+S` swaps panels, and `Ctrl+Alt+Left/Right` resizes Conversation along the current split direction.
@@ -134,7 +157,7 @@ Keyboard scrolling (`PgUp`/`PgDn` and Pi's other scroll keys) moves the active p
134
157
 
135
158
  ## Settings and limits
136
159
 
137
- Your layout, panel order, sizes, zoom, split on/off and timestamp settings are saved automatically and restored next time.
160
+ Your layout, panel order, sizes, zoom, split on/off, timestamp and copy-button settings are saved automatically and restored next time.
138
161
 
139
162
  - Tested with Pi 1.1.0. Requires Node.js 22.19 or newer.
140
163
  - Regular mode shows one combined history.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-stereo-view",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Keep Pi conversation separate from tool output and agent activity.",
6
6
  "license": "MIT",
package/src/content.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Container, type Component } from "@earendil-works/pi-tui";
1
+ import { Container, Markdown, Text, stripTerminalSequences, type Component } from "@earendil-works/pi-tui";
2
2
  import { searchText, type SearchEntry } from "./search.js";
3
3
 
4
4
  export type Pane = "activity" | "chat";
@@ -18,12 +18,15 @@ interface NativeContent extends Component {
18
18
  result?: { content?: unknown };
19
19
  command?: string;
20
20
  outputLines?: string[];
21
+ customComponent?: Component;
21
22
  setExpanded?: (expanded: boolean) => void;
22
23
  }
23
24
  interface NativeAssistant extends Container {
24
25
  contentContainer: Container;
25
26
  hasToolCalls: boolean;
26
27
  lastMessage?: { content: { type: string; text?: string; thinking?: string }[] };
28
+ contentIsStale?: boolean;
29
+ buildContent?(message: NonNullable<NativeAssistant["lastMessage"]>): void;
27
30
  setHideThinkingBlock(hidden: boolean): void;
28
31
  }
29
32
 
@@ -43,6 +46,12 @@ const contentText = (content: unknown): string => typeof content === "string" ?
43
46
  Array.isArray(content) ? content.filter(block => block?.type === "text" && typeof block.text === "string")
44
47
  .map(block => block.text).join("\n") : "";
45
48
 
49
+ /** Native text renderers retain their source before terminal wrapping and padding. */
50
+ function componentText(component: Component): string | undefined {
51
+ if (!(component instanceof Text) && !(component instanceof Markdown)) return undefined;
52
+ const text: unknown = Reflect.get(component, "text");
53
+ return typeof text === "string" ? stripTerminalSequences(text) : undefined;
54
+ }
46
55
  function assistant(component: Component): NativeAssistant | undefined {
47
56
  if (nameOf(component) !== "AssistantMessageComponent") return undefined;
48
57
  const native = component as NativeAssistant;
@@ -50,6 +59,14 @@ function assistant(component: Component): NativeAssistant | undefined {
50
59
  (native.lastMessage !== undefined && !Array.isArray(native.lastMessage.content))) {
51
60
  throw new Error("Pi's assistant content metadata is not supported");
52
61
  }
62
+ // Pi-Bolt builds streamed children lazily. Synchronize the real owner before
63
+ // indexing or cloning it, so a pane cannot rebuild both content kinds itself.
64
+ if (native.contentIsStale === true) {
65
+ if (!native.lastMessage || typeof native.buildContent !== "function") {
66
+ throw new Error("Pi's deferred assistant content is not supported");
67
+ }
68
+ native.buildContent(native.lastMessage);
69
+ }
53
70
  return native;
54
71
  }
55
72
 
@@ -82,6 +99,17 @@ export class NativeContentIndex {
82
99
  return this.content.some(entry => entry.source === source && (pane === undefined || panes[entry.kind] === pane));
83
100
  }
84
101
 
102
+ /** Build clipboard text only on demand, from this branch's current semantic block. */
103
+ copy(entry: ContentEntry, width: number): string | undefined {
104
+ this.refresh();
105
+ const current = this.content.find(candidate => candidate.source === entry.source && candidate.component === entry.component);
106
+ if (!current) return undefined;
107
+ const group = current.kind === "tool" && current.owner === current.source
108
+ ? this.content.filter(candidate => candidate === current || candidate.owner === current.source)
109
+ : [current];
110
+ return group.map(candidate => stripTerminalSequences(candidate.search(width).text)).filter(Boolean).join("\n\n");
111
+ }
112
+
85
113
  private refresh(): void {
86
114
  const history = this.source.children[2] as Container;
87
115
  if (!Array.isArray(history?.children)) throw new Error("Pi's history document is not supported");
@@ -120,7 +148,16 @@ export class NativeContentIndex {
120
148
  }
121
149
  spacing = [];
122
150
  }
123
- this.content = entries;
151
+ // Project late execution output beside its owner, never reorder native history.
152
+ const linked = new Map<Component, ContentEntry[]>();
153
+ for (const entry of entries) {
154
+ if (entry.owner === entry.source) continue;
155
+ const group = linked.get(entry.owner) ?? [];
156
+ group.push(entry);
157
+ linked.set(entry.owner, group);
158
+ }
159
+ this.content = entries.flatMap(entry => entry.owner === entry.source
160
+ ? [entry, ...(linked.get(entry.source) ?? [])] : []);
124
161
  this.signature = signature;
125
162
  }
126
163
 
@@ -148,7 +185,8 @@ export class NativeContentIndex {
148
185
  }
149
186
  return {
150
187
  label: owner?.toolName ?? (native.message?.summary !== undefined ? "Summary" : "System"),
151
- text: contentText(native.message?.content) || native.message?.summary || searchText(source.render(width).join("\n")),
188
+ text: contentText(native.message?.content) || native.message?.summary || componentText(native.customComponent ?? source) ||
189
+ searchText(source.render(width).join("\n")).split("\n").map(line => line.trimEnd()).join("\n").replace(/^\n+|\n+$/g, ""),
152
190
  reveal: typeof native.setExpanded === "function" ? () => native.setExpanded!(true) : undefined,
153
191
  };
154
192
  } };
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { getAgentDir, getSettingsListTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { copyToClipboard, getAgentDir, getSettingsListTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Box, Container, isKeyRelease, matchesKey, SettingsList, Text, type TUI } from "@earendil-works/pi-tui";
4
4
  import { TranscriptLayout } from "./layout.js";
5
5
  import { TOOL_START_ENTRY, TranscriptTimestamps } from "./timestamps.js";
@@ -38,12 +38,17 @@ export default async function transcript(pi: ExtensionAPI): Promise<void> {
38
38
  id: "timestamps", label: "Show timestamps",
39
39
  description: "Hide clock rows without changing messages or recorded tool starts.",
40
40
  currentValue: preferences.showTimestamps ? "on" : "off", values: ["on", "off"],
41
+ }, {
42
+ id: "copyButtons", label: "Show copy buttons",
43
+ description: "Hide copy icons and their rows; keyboard copying stays available.",
44
+ currentValue: preferences.showCopyButtons ? "on" : "off", values: ["on", "off"],
41
45
  }, {
42
46
  id: "height", label: "Conversation height",
43
47
  description: "Auto grows to half the history area. Drag sets a manual maximum.",
44
48
  currentValue: preferences.chatHeight === null ? "auto" : "manual", values: ["auto", "manual"],
45
49
  }], 3, getSettingsListTheme(), (id, value) => {
46
50
  if (id === "timestamps") layout?.setTimestamps(value === "on");
51
+ else if (id === "copyButtons") layout?.setCopyButtons(value === "on");
47
52
  else if (id === "height") layout?.setAutomaticHeight(value === "auto");
48
53
  tui.requestRender();
49
54
  }, () => done());
@@ -86,7 +91,18 @@ export default async function transcript(pi: ExtensionAPI): Promise<void> {
86
91
  timestamps = new TranscriptTimestamps(session);
87
92
  layout = new TranscriptLayout(preferences, () => ui.theme,
88
93
  message => ui.notify(message, "warning"), () => persist(ui), timestamps,
89
- () => { void openSettings(ui).catch(error => ui.notify(String(error), "error")); });
94
+ () => { void openSettings(ui).catch(error => ui.notify(String(error), "error")); }, text => {
95
+ if (text === undefined) {
96
+ ui.notify("This block is no longer in the current history.", "warning");
97
+ return;
98
+ }
99
+ if (!text) {
100
+ ui.notify("This block has no text to copy.", "info");
101
+ return;
102
+ }
103
+ void copyToClipboard(text).then(() => ui.notify("Block sent to clipboard.", "info"),
104
+ error => ui.notify("Could not copy block: " + String(error), "error"));
105
+ });
90
106
  try { ensureLayout(); }
91
107
  catch (error) {
92
108
  layout.dispose();
@@ -179,7 +195,7 @@ export default async function transcript(pi: ExtensionAPI): Promise<void> {
179
195
  ensureLayout();
180
196
  };
181
197
  pi.registerCommand("stereo-view", {
182
- description: "History: settings, toggle, swap, layout <columns|rows>, zoom <chat|activity>, width, height, scroll",
198
+ description: "History: copy, settings, toggle, swap, layout <columns|rows>, zoom <chat|activity>, width, height, scroll",
183
199
  handler: async (args, ctx) => {
184
200
  if (ctx.mode !== "tui" || !layout) {
185
201
  ctx.ui.notify("pi-stereo-view layout is available only in interactive Pi.", "warning");
@@ -188,8 +204,17 @@ export default async function transcript(pi: ExtensionAPI): Promise<void> {
188
204
  const [action = "toggle", target, amount] = args.trim().split(/\s+/);
189
205
  if (action === "toggle" || action === "") toggle();
190
206
  else if (action === "settings") await openSettings(ctx.ui);
191
- else if (action === "help") {
192
- ctx.ui.notify("pi-stereo-view icons: ▣ combined, ◫ split, ▤ rows, ▥ columns, ↕/↔ swap, ↗ expand, ↙ restore, ⌕ search, ≡ settings. Ctrl+F searches and PgUp/PgDn scroll the active panel; Ctrl+Alt+Up/Down switches it. Drag the ═ or ║ divider to resize.", "info");
207
+ else if (action === "copy") {
208
+ const blocks = layout.copyBlocks();
209
+ if (!blocks.length) {
210
+ ctx.ui.notify("No blocks to copy in the active split panel.", "info");
211
+ return;
212
+ }
213
+ closeSearch();
214
+ const choice = await ctx.ui.select("Copy a block from the active panel", blocks.map(block => block.label));
215
+ blocks.find(block => block.label === choice)?.copy();
216
+ } else if (action === "help") {
217
+ ctx.ui.notify("pi-stereo-view icons: ▣ combined, ◫ split, ▤ rows, ▥ columns, ↕/↔ swap, ↗ expand, ↙ restore, ⌕ search, ≡ settings, ⧉ copy block. /stereo-view copy chooses a block with the keyboard. Ctrl+F searches and PgUp/PgDn scroll the active panel; Ctrl+Alt+Up/Down switches it. Drag the ═ or ║ divider to resize.", "info");
193
218
  } else if (action === "swap") layout.swap();
194
219
  else if (action === "width" && /^\d+$/.test(target ?? "")) layout.setWidth(Number(target));
195
220
  else if (action === "height" && target === "auto") layout.setHeight(null);
@@ -199,7 +224,7 @@ export default async function transcript(pi: ExtensionAPI): Promise<void> {
199
224
  else if (action === "scroll" && (target === "chat" || target === "activity") && /^-?\d+$/.test(amount ?? "")) {
200
225
  layout.scroll(target, Number(amount));
201
226
  } else {
202
- ctx.ui.notify("Use /stereo-view [help|settings|toggle|swap|layout <columns|rows>|zoom <chat|activity>|width <columns>|height <rows|auto>|scroll <chat|activity> <lines>]", "warning");
227
+ ctx.ui.notify("Use /stereo-view [copy|help|settings|toggle|swap|layout <columns|rows>|zoom <chat|activity>|width <columns>|height <rows|auto>|scroll <chat|activity> <lines>]", "warning");
203
228
  return;
204
229
  }
205
230
  ensureLayout();
package/src/layout.ts CHANGED
@@ -134,6 +134,7 @@ export class TranscriptLayout {
134
134
  private readonly save: () => void,
135
135
  private readonly timestamps: TranscriptTimestamps,
136
136
  private readonly openSettings: () => void,
137
+ private readonly copy?: (text: string | undefined) => void,
137
138
  ) {}
138
139
 
139
140
  private button(icon: () => string, action: () => void, enabled = (): boolean => true, background?: () => Color): Component {
@@ -199,11 +200,12 @@ export class TranscriptLayout {
199
200
  // Auto scrollbars overlay one native content column; keep clocks outside it.
200
201
  const timestampGutter = (): number => scroll.scrollbar === "auto" ? 1 : 0;
201
202
  const showTimestamps = (): boolean => this.preferences.showTimestamps;
203
+ const showCopyButtons = (): boolean => this.preferences.showCopyButtons;
202
204
  const index = new NativeContentIndex(document);
203
205
  const activityDocument = new NativeDocument(document, "activity", this.timestamps, this.theme, timestampGutter, showTimestamps,
204
- () => this.activate("activity"), false, index);
206
+ () => this.activate("activity"), false, index, this.copy, showCopyButtons);
205
207
  const chatDocument = new NativeDocument(document, "chat", this.timestamps, this.theme, timestampGutter, showTimestamps,
206
- () => this.activate("chat"), false, index);
208
+ () => this.activate("chat"), false, index, this.copy, showCopyButtons);
207
209
  this.documents = { activity: activityDocument, chat: chatDocument };
208
210
  this.disposeDocuments = () => {
209
211
  activityDocument.dispose();
@@ -417,6 +419,11 @@ export class TranscriptLayout {
417
419
  return this.preferences.zoom ?? this.activePane;
418
420
  }
419
421
 
422
+ /** Offer block copying only from a currently visible split panel. */
423
+ copyBlocks(): ReturnType<NativeDocument["copyBlocks"]> {
424
+ const pane = this.searchPane();
425
+ return pane ? this.documents?.[pane].copyBlocks() ?? [] : [];
426
+ }
420
427
  private canFocusSearch(): boolean {
421
428
  const focused = this.host?.getFocusedComponent();
422
429
  if (!focused || focused === this.search?.bar.input) return true;
@@ -534,6 +541,11 @@ export class TranscriptLayout {
534
541
  this.changed();
535
542
  }
536
543
 
544
+ /** Hide copy icons without disabling the keyboard copy picker. */
545
+ setCopyButtons(show: boolean): void {
546
+ this.preferences.showCopyButtons = show;
547
+ this.changed();
548
+ }
537
549
  private defaultWidth(width: number): number {
538
550
  return Math.max(MIN_CHAT, Math.floor((width - 1) * 0.2));
539
551
  }
package/src/native.ts CHANGED
@@ -59,6 +59,7 @@ export class NativeDocument extends Container {
59
59
  private frameWidth?: number;
60
60
  private frameLines?: string[];
61
61
  private checked = false;
62
+ private disposed = false;
62
63
 
63
64
  constructor(
64
65
  private readonly source: Container,
@@ -70,6 +71,8 @@ export class NativeDocument extends Container {
70
71
  private readonly interact: () => void = () => {},
71
72
  private readonly invalidatesSource = true,
72
73
  private readonly index = new NativeContentIndex(source),
74
+ private readonly copy?: (text: string | undefined) => void,
75
+ private readonly showCopy: () => boolean = () => true,
73
76
  ) {
74
77
  super();
75
78
  }
@@ -106,6 +109,19 @@ export class NativeDocument extends Container {
106
109
  });
107
110
  }
108
111
 
112
+ /** Keyboard choices are built only when requested; callbacks recheck current ownership. */
113
+ copyBlocks(): { label: string; copy: () => void }[] {
114
+ return this.index.entries(this.pane).filter(entry => entry.compact).map((entry, index) => {
115
+ const saved = entry.search(this.lastWidth);
116
+ const preview = searchText(saved.text).replace(/\s+/g, " ").slice(0, 80);
117
+ return { label: `${index + 1}. ${saved.label}: ${preview}`, copy: () => this.copyBlock(entry.source) };
118
+ });
119
+ }
120
+
121
+ private copyBlock(source: Component): void {
122
+ const entry = this.disposed ? undefined : this.index.entries(this.pane).find(entry => entry.source === source);
123
+ this.copy?.(entry ? this.index.copy(entry, this.lastWidth) : undefined);
124
+ }
109
125
  /** Reject hits whose native owner was removed by a branch change or history rebuild. */
110
126
  containsSearchHit(hit: SearchHit): boolean {
111
127
  return this.index.contains(hit.entry.source, this.pane);
@@ -163,6 +179,7 @@ export class NativeDocument extends Container {
163
179
 
164
180
  /** Stop native observers when the layout is detached or falls back. */
165
181
  dispose(): void {
182
+ this.disposed = true;
166
183
  for (const block of this.compactBlocks.values()) block.dispose();
167
184
  this.compactBlocks.clear();
168
185
  this.frameWidth = undefined;
@@ -195,7 +212,8 @@ export class NativeDocument extends Container {
195
212
  else {
196
213
  let stamped = this.stamped.get(routed);
197
214
  if (!stamped) {
198
- stamped = new CompactMessage(routed, () => this.timestamps.label(this.clockOwners.get(component) ?? component), this.theme, this.timestampGutter, this.showTimestamps);
215
+ stamped = new CompactMessage(routed, () => this.timestamps.label(this.clockOwners.get(component) ?? component),
216
+ this.theme, this.timestampGutter, this.showTimestamps, this.copy ? () => this.copyBlock(component) : undefined, this.showCopy);
199
217
  this.stamped.set(routed, stamped);
200
218
  }
201
219
  this.compactBlocks.set(component, stamped);
@@ -14,10 +14,11 @@ export interface Preferences {
14
14
  chatHeight: number | null;
15
15
  zoom: "activity" | "chat" | null;
16
16
  showTimestamps: boolean;
17
+ showCopyButtons: boolean;
17
18
  }
18
19
 
19
20
  export const defaults: Preferences = {
20
- mode: "split", side: "left", chatWidth: null, layout: "columns", chatHeight: null, zoom: null, showTimestamps: true,
21
+ mode: "split", side: "left", chatWidth: null, layout: "columns", chatHeight: null, zoom: null, showTimestamps: true, showCopyButtons: true,
21
22
  };
22
23
 
23
24
  /** Read display preferences, retaining the first version's saved arrangement. */
@@ -36,16 +37,17 @@ export async function loadPreferences(file: string): Promise<Preferences> {
36
37
  const chatHeight = record.chatHeight === undefined ? defaults.chatHeight : record.chatHeight;
37
38
  const zoom = record.zoom === undefined ? defaults.zoom : record.zoom;
38
39
  const showTimestamps = record.showTimestamps === undefined ? defaults.showTimestamps : record.showTimestamps;
40
+ const showCopyButtons = record.showCopyButtons === undefined ? defaults.showCopyButtons : record.showCopyButtons;
39
41
  if ((record.mode !== "split" && record.mode !== "combined") ||
40
42
  (record.side !== "left" && record.side !== "right") ||
41
43
  (record.chatWidth !== null && (typeof record.chatWidth !== "number" || !Number.isInteger(record.chatWidth) ||
42
44
  record.chatWidth < 24 || record.chatWidth > 240)) ||
43
45
  (layout !== "columns" && layout !== "rows") ||
44
46
  (chatHeight !== null && (typeof chatHeight !== "number" || !Number.isInteger(chatHeight) || chatHeight < 4 || chatHeight > 240)) ||
45
- (zoom !== null && zoom !== "activity" && zoom !== "chat") || typeof showTimestamps !== "boolean") {
47
+ (zoom !== null && zoom !== "activity" && zoom !== "chat") || typeof showTimestamps !== "boolean" || typeof showCopyButtons !== "boolean") {
46
48
  throw new Error("Invalid pi-stereo-view preferences");
47
49
  }
48
- return { mode: record.mode, side: record.side, chatWidth: record.chatWidth, layout, chatHeight, zoom, showTimestamps };
50
+ return { mode: record.mode, side: record.side, chatWidth: record.chatWidth, layout, chatHeight, zoom, showTimestamps, showCopyButtons };
49
51
  }
50
52
 
51
53
  /** Atomically save only the display settings, never session messages. */
@@ -1,4 +1,23 @@
1
- import type { Component } from "@earendil-works/pi-tui";
1
+ import { stripTerminalSequences, type Component } from "@earendil-works/pi-tui";
2
+
3
+ const imagePlacement = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
4
+
5
+ function nativeLines(component: Component, width: number): string[] {
6
+ const lines = component.render(width);
7
+ const tool = component as Component & {
8
+ toolDefinition?: { renderShell?: string; renderCall?: unknown; renderResult?: unknown };
9
+ selfRenderHeight?: number;
10
+ };
11
+ const definition = tool.toolDefinition;
12
+ const height = tool.selfRenderHeight;
13
+ if (component.constructor.name !== "ToolExecutionComponent" || definition?.renderShell !== "self" ||
14
+ typeof definition.renderCall !== "function" || typeof definition.renderResult !== "function" ||
15
+ typeof height !== "number" || !Number.isInteger(height) || height < 0) return lines;
16
+ // Native self shells prepend one row, then append result images outside the custom UI.
17
+ // An intentionally empty custom UI must not be made visible by those automatic images.
18
+ const content = height ? lines.slice(1, height + 1) : [];
19
+ return content.some(line => imagePlacement(line) || stripTerminalSequences(line).trim().length > 0) ? lines : [];
20
+ }
2
21
 
3
22
  interface Subscription {
4
23
  original: Component["invalidate"];
@@ -180,9 +199,9 @@ export class NativeRenderCache {
180
199
  const initial = components(this.component);
181
200
  const observed = this.watch(initial);
182
201
  const before = this.revision;
183
- const lines = this.component.render(width);
202
+ const lines = nativeLines(this.component, width);
184
203
  this.outputVersion++;
185
- this.imagePlacement = lines.some(line => line.includes("\x1b_G") || line.includes("\x1b]1337;File="));
204
+ this.imagePlacement = lines.some(imagePlacement);
186
205
  const links: NodeLinks[] = [];
187
206
  const after = components(this.component, links);
188
207
  const stableGraph = initial.length === after.length &&
@@ -17,8 +17,8 @@ const normalize = (text: string, lettersOnly: boolean): string => text.normalize
17
17
  .replace(lettersOnly ? /[^\p{L}\p{N}]/gu : /\s/gu, "");
18
18
  interface Position { row: number; start: number; end: number }
19
19
 
20
- /** An actual clock inserted by CompactMessage, not a suffix guessed from saved text. */
21
- export interface SearchClock { row: number; column: number }
20
+ /** Presentation-only rows supplied by CompactMessage, never guessed from saved text. */
21
+ export interface SearchClock { row: number; column: number; footerRow?: number }
22
22
 
23
23
  function locate(lines: string[], hit: SearchHit, lettersOnly: boolean, clock?: SearchClock): { text: string; positions: Position[] } | undefined {
24
24
  const wanted = normalize(hit.text, lettersOnly);
@@ -26,6 +26,7 @@ function locate(lines: string[], hit: SearchHit, lettersOnly: boolean, clock?: S
26
26
  let text = "";
27
27
  const positions: Position[] = [];
28
28
  for (const [row, line] of lines.entries()) {
29
+ if (clock?.footerRow === row) continue;
29
30
  // Only the decorator knows where its clock starts. Saved trailing times remain text.
30
31
  const plain = stripTerminalSequences(clock?.row === row ? sliceByColumn(line, 0, clock.column) : line);
31
32
  for (const { segment, index } of graphemes.segment(plain)) {
package/src/timestamps.ts CHANGED
@@ -191,20 +191,29 @@ export class TranscriptTimestamps {
191
191
  }
192
192
  }
193
193
 
194
- /** Trim message edges and reserve a clock gutter without changing native text or mouse targets. */
194
+ // Reserve the glyph's painted overhang, without a label or surrounding frame.
195
+ const COPY_CONTROL = "⧉ ";
196
+ const COPY_CONTROL_WIDTH = visibleWidth(COPY_CONTROL);
197
+ /** Keep message text full-width, with a clock above and copy action below. */
195
198
  export class CompactMessage extends Container {
196
199
  private top = 0;
197
200
  private contentWidth = 0;
198
201
  private contentHeight = 0;
199
202
  private hasClock = false;
203
+ private headerRows = 0;
204
+ private bodyRows = 0;
205
+ private copyRow?: number;
206
+ private copyColumn?: number;
207
+ private copyPressed = false;
200
208
  private leadingRows = 0;
201
209
  private prepared?: { width: number; content: string[]; lines: string[] };
202
210
  private readonly nativeCache: NativeRenderCache;
203
- private formatted?: { width: number; time: string; gutter: number; theme: Theme; version: number; top: number; lines: string[] };
211
+ private formatted?: { width: number; time: string; copy: string; gutter: number; theme: Theme; version: number; top: number; lines: string[] };
204
212
 
205
213
  constructor(private readonly component: Component, private readonly label: () => string,
206
214
  private readonly theme: () => Theme, private readonly gutter: () => number,
207
- private readonly show: () => boolean) {
215
+ private readonly show: () => boolean, private readonly copy?: () => void,
216
+ private readonly showCopy: () => boolean = () => true) {
208
217
  super();
209
218
  this.addChild(component);
210
219
  this.nativeCache = new NativeRenderCache(component);
@@ -227,57 +236,87 @@ export class CompactMessage extends Container {
227
236
  return this.renderContent(width);
228
237
  }
229
238
 
230
- /** Report only the clock actually inserted into the prepared block's rows. */
231
- searchClock(): { row: number; column: number } | undefined {
232
- return this.hasClock ? { row: this.leadingRows, column: this.nativeCache.hasImagePlacement ? 0 : this.contentWidth } : undefined;
239
+ /** Exclude presentation rows when mapping saved-text search matches. */
240
+ searchClock(): { row: number; column: number; footerRow?: number } | undefined {
241
+ if (!this.hasClock && this.copyRow === undefined) return undefined;
242
+ return {
243
+ row: this.hasClock ? this.leadingRows : -1, column: 0,
244
+ ...(this.copyRow === undefined ? {} : { footerRow: this.leadingRows + this.copyRow }),
245
+ };
233
246
  }
234
247
  private renderContent(width: number): string[] {
235
248
  const label = this.show() ? this.label() : "";
236
249
  const gutter = this.gutter();
237
250
  const theme = this.theme();
238
- const time = width > label.length + gutter + 1 ? label : "";
239
- this.hasClock = Boolean(time);
240
- this.contentWidth = Math.max(1, width - (time ? time.length + 1 + gutter : 0));
251
+ // Only the scrollbar needs a side gutter; controls have their own rows.
252
+ this.contentWidth = Math.max(1, width - gutter);
253
+ const time = label && visibleWidth(label) + 1 <= this.contentWidth ? " " + label : "";
254
+ const copy = this.copy && this.showCopy() && this.contentWidth >= COPY_CONTROL_WIDTH ? COPY_CONTROL : "";
241
255
  const content = this.nativeCache.render(this.contentWidth, theme);
242
256
  this.contentHeight = content.length;
243
257
  const cached = this.formatted;
244
- if (cached?.width === width && cached.time === time && cached.gutter === gutter && cached.theme === theme &&
258
+ if (cached?.width === width && cached.time === time && cached.copy === copy && cached.gutter === gutter && cached.theme === theme &&
245
259
  cached.version === this.nativeCache.version) {
246
260
  this.top = cached.top;
247
261
  return cached.lines;
248
262
  }
249
263
  this.formatted = undefined;
250
- // Image protocols need every placement row. Keep their native block intact.
251
- if (this.nativeCache.hasImagePlacement) {
252
- this.top = time ? -1 : 0;
253
- return time ? [theme.fg("dim", time.padStart(width - gutter)), ...content] : content;
254
- }
255
264
  this.top = 0;
265
+ this.hasClock = false;
266
+ this.headerRows = 0;
267
+ this.bodyRows = 0;
268
+ this.copyRow = undefined;
269
+ this.copyColumn = undefined;
256
270
  let end = content.length;
257
- while (this.top < end && blank(content[this.top])) this.top++;
258
- while (end > this.top && blank(content[end - 1])) end--;
259
- const lines = content.slice(this.top, end);
260
- if (!lines.length) {
261
- this.hasClock = false;
262
- return lines;
271
+ // Image protocols need every placement row. Keep their native block intact.
272
+ if (!this.nativeCache.hasImagePlacement) {
273
+ while (this.top < end && blank(content[this.top])) this.top++;
274
+ while (end > this.top && blank(content[end - 1])) end--;
263
275
  }
264
- // User message zones live on native padding rows. Keep those shell markers.
265
- const zones = (rows: string[]): string => rows.join("").match(/\x1b\]133;[ABC](?:\x07|\x1b\\)/g)?.join("") ?? "";
266
- lines[0] = zones(content.slice(0, this.top)) + lines[0];
267
- lines[lines.length - 1] += zones(content.slice(end));
268
- if (time) {
269
- lines[0] += "\x1b[0m\x1b]8;;\x07" +
270
- " ".repeat(Math.max(1, width - gutter - time.length - visibleWidth(lines[0]))) +
271
- theme.fg("dim", time);
276
+ const body = content.slice(this.top, end);
277
+ if (!body.length) return body;
278
+ if (!this.nativeCache.hasImagePlacement) {
279
+ // User message zones live on native padding rows. Keep those shell markers.
280
+ const zones = (rows: string[]): string => rows.join("").match(/\x1b\]133;[ABC](?:\x07|\x1b\\)/g)?.join("") ?? "";
281
+ body[0] = zones(content.slice(0, this.top)) + body[0];
282
+ body[body.length - 1] += zones(content.slice(end));
272
283
  }
273
- this.formatted = { width, time, gutter, theme, version: this.nativeCache.version, top: this.top, lines };
284
+ this.hasClock = Boolean(time);
285
+ this.headerRows = time ? 1 : 0;
286
+ this.bodyRows = body.length;
287
+ const lines = time ? [theme.fg("dim", time), ...body] : body;
288
+ if (copy) {
289
+ this.copyRow = lines.length;
290
+ this.copyColumn = this.contentWidth - COPY_CONTROL_WIDTH;
291
+ lines.push(" ".repeat(this.copyColumn) + theme.style(copy, { fg: "dim", dim: true }));
292
+ }
293
+ this.formatted = { width, time, copy, gutter, theme, version: this.nativeCache.version, top: this.top, lines };
274
294
  return lines;
275
295
  }
276
296
 
277
297
  override handleMouse(event: TuiMouseEvent): ReturnType<Container["handleMouse"]> {
278
- if (event.y < this.leadingRows || event.x >= this.contentWidth) return undefined;
298
+ const target = { component: this, originX: event.screenX - event.x, originY: event.screenY - event.y,
299
+ width: event.width, height: event.height };
300
+ const onCopy = this.copyColumn !== undefined && this.copyRow !== undefined &&
301
+ event.y === this.leadingRows + this.copyRow && event.x >= this.copyColumn &&
302
+ event.x < this.copyColumn + COPY_CONTROL_WIDTH && event.button === "left";
303
+ if (onCopy && event.type === "press") {
304
+ this.copyPressed = true;
305
+ return { handled: true, capture: true, target };
306
+ }
307
+ if (onCopy && event.type === "click") {
308
+ this.copyPressed = false;
309
+ this.copy?.();
310
+ return { handled: true, target };
311
+ }
312
+ if (this.copyPressed) {
313
+ if (event.type === "release") this.copyPressed = false;
314
+ return { handled: true, target };
315
+ }
316
+ const bodyRow = event.y - this.leadingRows - this.headerRows;
317
+ if (bodyRow < 0 || bodyRow >= this.bodyRows || event.x >= this.contentWidth) return undefined;
279
318
  return super.handleMouse({
280
- ...event, y: event.y - this.leadingRows + this.top, width: this.contentWidth, height: this.contentHeight,
319
+ ...event, y: bodyRow + this.top, width: this.contentWidth, height: this.contentHeight,
281
320
  });
282
321
  }
283
322