pi-stereo-view 0.1.4 → 0.1.6
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 +18 -0
- package/README.md +26 -5
- package/package.json +1 -1
- package/src/content.ts +21 -2
- package/src/index.ts +31 -6
- package/src/layout.ts +14 -2
- package/src/native.ts +19 -1
- package/src/preferences.ts +5 -3
- package/src/search-highlight.ts +3 -2
- package/src/timestamps.ts +76 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.6 - 2026-10-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Hide clock placeholders and copy buttons for blocks without a known timestamp, including system notices. Their content remains visible without extra control rows.
|
|
8
|
+
|
|
9
|
+
## 0.1.5 - 2026-10-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- Use `/stereo-view copy` to choose and copy a block from the active panel with the keyboard.
|
|
15
|
+
- Hide copy icons and their rows with **Show copy buttons** without disabling keyboard copying. The setting survives restart.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Show timestamps above blocks on the left and dim copy icons below on the right, without reserving a side column beside the text.
|
|
20
|
+
|
|
3
21
|
## 0.1.4 - 2026-10-20
|
|
4
22
|
|
|
5
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -29,6 +29,14 @@ Late output linked to a tool call appears directly below that call in Activity,
|
|
|
29
29
|
|
|
30
30
|
Messages and tools keep their familiar appearance. The input stays full width below both panels.
|
|
31
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
|
+
|
|
32
40
|
## See it in action
|
|
33
41
|
|
|
34
42
|

|
|
@@ -69,7 +77,7 @@ Click the toolbar and the buttons in each panel's header:
|
|
|
69
77
|
| `[ ▤ ]` / `[ ▥ ]` | Switch between rows and columns. |
|
|
70
78
|
| `[ ↕ ]` / `[ ↔ ]` | Swap the panels. |
|
|
71
79
|
| `[ ↗ ]` / `[ ↙ ]` | Zoom a panel / restore the split. |
|
|
72
|
-
| `[ ≡ ]` | Open
|
|
80
|
+
| `[ ≡ ]` | Open timestamp, copy-button and Conversation height settings. |
|
|
73
81
|
| `[ ⌕ ]` | Search this panel. |
|
|
74
82
|
|
|
75
83
|
**Resize:** drag the double line between the panels — `═` in rows, `║` in columns.
|
|
@@ -105,11 +113,23 @@ A match is revealed in its own panel without moving the other one. If matching t
|
|
|
105
113
|
|
|
106
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.
|
|
107
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. Blocks without a known time have no copy button. 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
|
+
|
|
108
128
|
## Timestamps
|
|
109
129
|
|
|
110
|
-
Conversation and Activity can show a dim local `HH:mm:ss` clock
|
|
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.
|
|
111
131
|
|
|
112
|
-
Older tool calls may show `~HH:mm:ss` for an approximate time.
|
|
132
|
+
Older tool calls may show `~HH:mm:ss` for an approximate time. Blocks without a known time show neither a clock nor a copy button; their content stays visible.
|
|
113
133
|
|
|
114
134
|
Use `≡` or `/stereo-view settings` to hide or show clocks. This setting survives restart. Clocks appear in split and zoomed panels, not in combined history. They are display-only and are not added to the conversation sent to the model.
|
|
115
135
|
|
|
@@ -127,7 +147,8 @@ The same controls are available without the mouse:
|
|
|
127
147
|
| Scroll a panel | `/stereo-view scroll chat -10` / `/stereo-view scroll activity -10` |
|
|
128
148
|
| Zoom / restore Conversation | `/stereo-view zoom chat` |
|
|
129
149
|
| Zoom / restore Activity | `/stereo-view zoom activity` |
|
|
130
|
-
|
|
|
150
|
+
| Copy a block from the active panel | `/stereo-view copy` |
|
|
151
|
+
| Display settings | `/stereo-view settings` |
|
|
131
152
|
| Button legend | `/stereo-view help` |
|
|
132
153
|
|
|
133
154
|
`Ctrl+Alt+T` toggles split, `Ctrl+Alt+S` swaps panels, and `Ctrl+Alt+Left/Right` resizes Conversation along the current split direction.
|
|
@@ -136,7 +157,7 @@ Keyboard scrolling (`PgUp`/`PgDn` and Pi's other scroll keys) moves the active p
|
|
|
136
157
|
|
|
137
158
|
## Settings and limits
|
|
138
159
|
|
|
139
|
-
Your layout, panel order, sizes, zoom, split on/off and
|
|
160
|
+
Your layout, panel order, sizes, zoom, split on/off, timestamp and copy-button settings are saved automatically and restored next time.
|
|
140
161
|
|
|
141
162
|
- Tested with Pi 1.1.0. Requires Node.js 22.19 or newer.
|
|
142
163
|
- Regular mode shows one combined history.
|
package/package.json
CHANGED
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,6 +18,7 @@ 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 {
|
|
@@ -45,6 +46,12 @@ const contentText = (content: unknown): string => typeof content === "string" ?
|
|
|
45
46
|
Array.isArray(content) ? content.filter(block => block?.type === "text" && typeof block.text === "string")
|
|
46
47
|
.map(block => block.text).join("\n") : "";
|
|
47
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
|
+
}
|
|
48
55
|
function assistant(component: Component): NativeAssistant | undefined {
|
|
49
56
|
if (nameOf(component) !== "AssistantMessageComponent") return undefined;
|
|
50
57
|
const native = component as NativeAssistant;
|
|
@@ -92,6 +99,17 @@ export class NativeContentIndex {
|
|
|
92
99
|
return this.content.some(entry => entry.source === source && (pane === undefined || panes[entry.kind] === pane));
|
|
93
100
|
}
|
|
94
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
|
+
|
|
95
113
|
private refresh(): void {
|
|
96
114
|
const history = this.source.children[2] as Container;
|
|
97
115
|
if (!Array.isArray(history?.children)) throw new Error("Pi's history document is not supported");
|
|
@@ -167,7 +185,8 @@ export class NativeContentIndex {
|
|
|
167
185
|
}
|
|
168
186
|
return {
|
|
169
187
|
label: owner?.toolName ?? (native.message?.summary !== undefined ? "Summary" : "System"),
|
|
170
|
-
text: contentText(native.message?.content) || native.message?.summary ||
|
|
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, ""),
|
|
171
190
|
reveal: typeof native.setExpanded === "function" ? () => native.setExpanded!(true) : undefined,
|
|
172
191
|
};
|
|
173
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 === "
|
|
192
|
-
|
|
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),
|
|
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);
|
package/src/preferences.ts
CHANGED
|
@@ -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. */
|
package/src/search-highlight.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
@@ -32,7 +32,7 @@ const validTime = (value: unknown): number | undefined => {
|
|
|
32
32
|
/** Format a reliable source in the process's local zone; never substitute now. */
|
|
33
33
|
export function timestampLabel(value: unknown): string {
|
|
34
34
|
const time = validTime(value);
|
|
35
|
-
if (time === undefined) return "
|
|
35
|
+
if (time === undefined) return "";
|
|
36
36
|
const date = new Date(time);
|
|
37
37
|
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
38
38
|
.map(part => String(part).padStart(2, "0")).join(":");
|
|
@@ -176,35 +176,44 @@ export class TranscriptTimestamps {
|
|
|
176
176
|
toolCallId?: string;
|
|
177
177
|
};
|
|
178
178
|
if (name === "UserMessageComponent" || name === "SkillInvocationMessageComponent" || name === "BashExecutionComponent") {
|
|
179
|
-
return this.orderedLabels.get(component) ?? "
|
|
179
|
+
return this.orderedLabels.get(component) ?? "";
|
|
180
180
|
}
|
|
181
181
|
if (name === "ToolExecutionComponent") {
|
|
182
182
|
const id = native.toolCallId!;
|
|
183
183
|
const start = this.starts.get(id);
|
|
184
184
|
if (start !== undefined) return timestampLabel(start);
|
|
185
|
-
if (this.liveCalls.has(id)) return "
|
|
185
|
+
if (this.liveCalls.has(id)) return "";
|
|
186
186
|
const owner = this.owners.get(id);
|
|
187
|
-
return validTime(owner) === undefined ? "
|
|
187
|
+
return validTime(owner) === undefined ? "" : "~" + timestampLabel(owner);
|
|
188
188
|
}
|
|
189
189
|
const message = name === "AssistantMessageComponent" ? native.lastMessage : native.message;
|
|
190
190
|
return timestampLabel(message ? validTime(message.timestamp) ?? this.messageTimes.get(message) : native.entry?.timestamp);
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
|
|
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
|
-
/**
|
|
231
|
-
searchClock(): { row: number; column: number } | undefined {
|
|
232
|
-
|
|
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
|
-
const label = this.
|
|
248
|
+
const label = this.label();
|
|
236
249
|
const gutter = this.gutter();
|
|
237
250
|
const theme = this.theme();
|
|
238
|
-
|
|
239
|
-
this.
|
|
240
|
-
|
|
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 && this.show() && visibleWidth(label) + 1 <= this.contentWidth ? " " + label : "";
|
|
254
|
+
const copy = label && 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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
319
|
+
...event, y: bodyRow + this.top, width: this.contentWidth, height: this.contentHeight,
|
|
281
320
|
});
|
|
282
321
|
}
|
|
283
322
|
|