pi-message-sidebar 1.1.0

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 ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ > Pi 0.84.4: transcript overlap during TUI mode switches and narrow-pane width failures are resolved by mode-specific reserved layouts.
6
+
7
+ - Changed development installation to a directory symlink with `index.ts`, so relative `src/` imports resolve through Pi's global extension auto-discovery.
8
+ - Added a real auto-discovery load test for the installed extension shape.
9
+ - Removed the persistent overlay implementation.
10
+ - Added reserved-width rendering for regular TUI mode.
11
+ - Added native `HStack` layout integration for fullscreen TUI mode.
12
+ - Added responsive collapse below 123 terminal columns.
13
+ - Added ANSI-safe truncation and width assertions for every sidebar row.
14
+ - Preserved the session-path copy interaction from the existing working tree.
15
+ - Split the extension into focused source modules, each below 400 lines.
16
+ - Added unit tests and real pseudo-terminal smoke tests at wide and narrow sizes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Francesco Frapporti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # pi-message-sidebar
2
+
3
+ Persistent message history sidebar for [Pi](https://pi.dev).
4
+
5
+ ## Features
6
+
7
+ - Fixed 42-column panel on the right
8
+ - Main transcript and editor render in their own reserved width
9
+ - Native side-by-side layout in fullscreen TUI mode
10
+ - Regular-mode compositor that reserves the same width in scrollback mode
11
+ - Automatic collapse when the terminal cannot keep an 80-column main pane
12
+ - First and last five user messages remain visible
13
+ - Gap indicator shows hidden message counts
14
+ - `Ctrl+Shift+H` focuses the sidebar
15
+ - Arrow keys navigate messages
16
+ - `Enter` expands or collapses a message
17
+ - `c` copies the session path
18
+ - `Escape` returns focus to Pi
19
+ - Width assertions cover every rendered sidebar line
20
+
21
+ ## Requirements
22
+
23
+ Pi 0.84.4 or newer. This extension uses the renderer-switching and fullscreen layout APIs shipped with the 0.84 series.
24
+
25
+ ## Installation
26
+
27
+ ### Development symlink
28
+
29
+ ```bash
30
+ git clone git@github.com:Fornace/pi-message-sidebar.git ~/repos/pi-message-sidebar
31
+ ln -s ~/repos/pi-message-sidebar ~/.pi/agent/extensions/message-sidebar
32
+ ```
33
+
34
+ ### Pi package
35
+
36
+ ```bash
37
+ pi install npm:pi-message-sidebar
38
+ # or from git
39
+ pi install git:github.com/Fornace/pi-message-sidebar
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ The sidebar appears automatically in interactive mode when the terminal is at least 123 columns wide. It collapses below that breakpoint so Pi keeps a usable main pane.
45
+
46
+ - Press `Ctrl+Shift+H` to focus or unfocus the sidebar.
47
+ - Press `↑` or `↓` to navigate.
48
+ - Press `PageUp`, `PageDown`, `Home`, or `End` for larger jumps.
49
+ - Press `Enter` to expand or collapse the selected message.
50
+ - Press `c` to copy the current session path.
51
+ - Press `Escape` to return focus to Pi.
52
+
53
+ ## Architecture
54
+
55
+ - `index.ts` is the auto-discovered extension entrypoint.
56
+ - `src/layout.ts` reserves a real horizontal region in fullscreen mode and composes an equivalent region in regular mode.
57
+ - `src/sidebar-component.ts` owns message navigation and bounded rendering.
58
+ - `src/status-dock.ts` renders session, model, context, cost, and extension status data.
59
+ - `src/style.ts` provides ANSI-safe row filling and width helpers.
60
+ - `src/constants.ts` owns responsive layout thresholds.
61
+
62
+ Every source file stays below 400 lines.
63
+
64
+ ## License
65
+
66
+ MIT
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./message-sidebar.ts";
2
+ export { collectUserMessages } from "./message-sidebar.ts";
@@ -0,0 +1,162 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ReadonlyFooterDataProvider,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { TUI } from "@earendil-works/pi-tui";
7
+ import { isViewportTUI, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
8
+ import { isSidebarVisible } from "./src/constants.ts";
9
+ import { SidebarLayoutBridge } from "./src/layout.ts";
10
+ import { SidebarComponent, type UserMessage } from "./src/sidebar-component.ts";
11
+
12
+ function extractUserText(message: { content: unknown }): string {
13
+ if (typeof message.content === "string") return message.content;
14
+ if (!Array.isArray(message.content)) return "";
15
+ return message.content
16
+ .filter((content): content is { type: "text"; text: string } => {
17
+ return typeof content === "object" && content !== null && (content as any).type === "text";
18
+ })
19
+ .map((content) => content.text)
20
+ .join(" ");
21
+ }
22
+
23
+ export function collectUserMessages(ctx: ExtensionContext): UserMessage[] {
24
+ const messages: UserMessage[] = [];
25
+ let index = 0;
26
+ for (const entry of ctx.sessionManager.getBranch()) {
27
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
28
+ index++;
29
+ const text = extractUserText(entry.message).trim();
30
+ if (text) messages.push({ id: entry.id, text, index, timestamp: entry.timestamp });
31
+ }
32
+ return messages;
33
+ }
34
+
35
+ class FooterDataBridge {
36
+ constructor(
37
+ private readonly tui: TUI,
38
+ private readonly ctx: ExtensionContext,
39
+ footerData: ReadonlyFooterDataProvider,
40
+ private readonly onChange: () => void,
41
+ private readonly onDispose: () => void,
42
+ ) {
43
+ this.unsubscribe = footerData.onBranchChange(onChange);
44
+ }
45
+
46
+ private readonly unsubscribe: () => void;
47
+
48
+ render(width: number): string[] {
49
+ if (isSidebarVisible(this.tui.terminal.columns)) return [];
50
+ const model = this.ctx.model?.id ?? "no model";
51
+ const cwd = this.ctx.sessionManager.getCwd();
52
+ return [truncateToWidth(`${model} ${cwd}`, width, "…")];
53
+ }
54
+
55
+ invalidate(): void { this.onChange(); }
56
+ dispose(): void { this.unsubscribe(); this.onDispose(); }
57
+ }
58
+
59
+ export default function messageSidebar(pi: ExtensionAPI): void {
60
+ let sidebar: SidebarComponent | null = null;
61
+ let tui: TUI | null = null;
62
+ let cachedContext: ExtensionContext | null = null;
63
+ let footerData: ReadonlyFooterDataProvider | null = null;
64
+ let refreshQueued = false;
65
+
66
+ const scheduleRefresh = (ctx: ExtensionContext | null = cachedContext) => {
67
+ if (refreshQueued) return;
68
+ refreshQueued = true;
69
+ setImmediate(() => {
70
+ refreshQueued = false;
71
+ if (sidebar && ctx) sidebar.updateMessages(collectUserMessages(ctx));
72
+ else sidebar?.refresh();
73
+ });
74
+ };
75
+
76
+ const toggleFocus = (ctx: ExtensionContext) => {
77
+ const activeTui = tui;
78
+ if (!sidebar || !activeTui) {
79
+ ctx.ui.notify("Sidebar is still initializing", "warning");
80
+ return;
81
+ }
82
+ if (!isSidebarVisible(activeTui.terminal.columns)) {
83
+ ctx.ui.notify("Sidebar needs a terminal width of at least 123 columns", "warning");
84
+ return;
85
+ }
86
+ const focused = !sidebar.isFocused();
87
+ sidebar.setFocused(focused);
88
+ activeTui.requestRender();
89
+ };
90
+
91
+ pi.on("session_start", (_event, ctx) => {
92
+ if (ctx.mode !== "tui") return;
93
+ cachedContext = ctx;
94
+ ctx.ui.setWidget(
95
+ "message-sidebar-layout",
96
+ (currentTui) => {
97
+ tui = currentTui;
98
+ sidebar = new SidebarComponent({
99
+ tui: currentTui,
100
+ ctx,
101
+ getFooterData: () => footerData,
102
+ getThinkingLevel: () => pi.getThinkingLevel(),
103
+ messages: collectUserMessages(ctx),
104
+ });
105
+ return new SidebarLayoutBridge(currentTui, sidebar);
106
+ },
107
+ { placement: "belowEditor" },
108
+ );
109
+
110
+ ctx.ui.setFooter((currentTui, _theme, data) => {
111
+ footerData = data;
112
+ scheduleRefresh(ctx);
113
+ return new FooterDataBridge(currentTui, ctx, data, () => scheduleRefresh(ctx), () => {
114
+ if (footerData === data) footerData = null;
115
+ });
116
+ });
117
+
118
+ ctx.ui.onTerminalInput((data) => {
119
+ if (tui && !isSidebarVisible(tui.terminal.columns) && sidebar?.isFocused()) {
120
+ sidebar.setFocused(false);
121
+ tui.requestRender();
122
+ return undefined;
123
+ }
124
+ if (tui && isViewportTUI(tui)) return undefined;
125
+ if (matchesKey(data, "escape") && sidebar?.isFocused()) {
126
+ sidebar.setFocused(false);
127
+ tui?.requestRender();
128
+ return { consume: true };
129
+ }
130
+ if (sidebar?.isFocused()) {
131
+ sidebar.handleInput(data);
132
+ return { consume: true };
133
+ }
134
+ return undefined;
135
+ });
136
+ });
137
+
138
+ pi.on("session_shutdown", () => {
139
+ sidebar = null;
140
+ tui = null;
141
+ cachedContext = null;
142
+ footerData = null;
143
+ });
144
+
145
+ pi.registerShortcut("ctrl+shift+h", {
146
+ description: "Focus or unfocus message sidebar",
147
+ handler: async (ctx) => toggleFocus(ctx),
148
+ });
149
+
150
+ pi.registerCommand("sidebar", {
151
+ description: "Focus or unfocus message sidebar (Ctrl+Shift+H)",
152
+ handler: async (_arguments, ctx) => toggleFocus(ctx),
153
+ });
154
+
155
+ pi.on("message_end", (_event, ctx) => scheduleRefresh(ctx));
156
+ pi.on("turn_end", (_event, ctx) => scheduleRefresh(ctx));
157
+ pi.on("agent_end", (_event, ctx) => scheduleRefresh(ctx));
158
+ pi.on("model_select", (_event, ctx) => scheduleRefresh(ctx));
159
+ pi.on("thinking_level_select", (_event, ctx) => scheduleRefresh(ctx));
160
+ pi.on("session_compact", (_event, ctx) => scheduleRefresh(ctx));
161
+ pi.on("session_tree", (_event, ctx) => scheduleRefresh(ctx));
162
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-message-sidebar",
3
+ "version": "1.1.0",
4
+ "description": "Persistent responsive message history sidebar for Pi",
5
+ "keywords": ["pi-package", "pi-extension", "sidebar", "tui"],
6
+ "author": "Francesco Frapporti <effedue@gmail.com>",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Fornace/pi-message-sidebar.git"
12
+ },
13
+ "files": ["index.ts", "message-sidebar.ts", "src", "README.md", "CHANGELOG.md", "LICENSE"],
14
+ "pi": {
15
+ "extensions": ["./index.ts"]
16
+ },
17
+ "scripts": {
18
+ "test": "node --import tsx --test test/*.test.ts",
19
+ "test:pty": "node test/pty-smoke.mjs regular 142 55 && node test/pty-smoke.mjs regular 121 55 && node test/pty-smoke.mjs fullscreen 142 55 && node test/pty-smoke.mjs fullscreen 121 55",
20
+ "typecheck": "tsc --noEmit",
21
+ "test:load": "node --import tsx test/load.test.mjs"
22
+ },
23
+ "devDependencies": {
24
+ "@earendil-works/pi-coding-agent": "0.84.4",
25
+ "@earendil-works/pi-tui": "0.84.4",
26
+ "@types/node": "^24.0.0",
27
+ "node-pty": "^1.1.0",
28
+ "tsx": "^4.20.0",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": ">=0.84.4"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19.0"
36
+ }
37
+ }
@@ -0,0 +1,10 @@
1
+ export const SIDEBAR_WIDTH = 42;
2
+ export const SIDEBAR_GAP = 1;
3
+ export const RESERVED_WIDTH = SIDEBAR_WIDTH + SIDEBAR_GAP;
4
+ export const MIN_MAIN_WIDTH = 80;
5
+ export const PINNED_COUNT = 5;
6
+ export const GAP_WINDOW = 3;
7
+
8
+ export function isSidebarVisible(terminalWidth: number): boolean {
9
+ return terminalWidth >= MIN_MAIN_WIDTH + RESERVED_WIDTH;
10
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,196 @@
1
+ import type { Component, TUI, ViewportTUI } from "@earendil-works/pi-tui";
2
+ import {
3
+ HStack,
4
+ TuiAltScreen,
5
+ TuiMainScreen,
6
+ compositeTuiLine,
7
+ isViewportTUI,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { MIN_MAIN_WIDTH, RESERVED_WIDTH, SIDEBAR_WIDTH, isSidebarVisible } from "./constants.ts";
11
+
12
+ const REGULAR_PATCH_KEY = Symbol.for("pi-message-sidebar.regular-layout");
13
+ const FULLSCREEN_PATCH_KEY = Symbol.for("pi-message-sidebar.fullscreen-layout");
14
+
15
+ type RegularPatchState = {
16
+ originalRender: (this: TuiMainScreen, width: number) => string[];
17
+ refs: number;
18
+ sidebar: Component;
19
+ };
20
+
21
+ type FullscreenPatchState = {
22
+ originalSetLayoutRoot: (this: ViewportTUI, root: Component | undefined) => void;
23
+ refs: number;
24
+ sidebar: Component;
25
+ latestRoot?: Component;
26
+ roots: WeakMap<object, Component>;
27
+ };
28
+
29
+ function defocusHiddenSidebar(tui: TUI, sidebar: Component, width: number): boolean {
30
+ const visible = isSidebarVisible(width);
31
+ if (!visible && (sidebar as any).isFocused?.()) {
32
+ (sidebar as any).setFocused(false);
33
+ if ((tui as any).getFocusedComponent?.() === sidebar) tui.setFocus(null);
34
+ }
35
+ return visible;
36
+ }
37
+
38
+ function renderRegularLayout(
39
+ renderer: TuiMainScreen,
40
+ state: RegularPatchState,
41
+ width: number,
42
+ ): string[] {
43
+ if (!defocusHiddenSidebar(renderer, state.sidebar, width)) {
44
+ return state.originalRender.call(renderer, width);
45
+ }
46
+
47
+ const mainWidth = width - RESERVED_WIDTH;
48
+ const mainLines = state.originalRender.call(renderer, mainWidth);
49
+ const terminalRows = Math.max(1, renderer.terminal.rows);
50
+ const sidebarLines = state.sidebar.render(SIDEBAR_WIDTH).slice(0, terminalRows);
51
+ const viewportStart = Math.max(0, mainLines.length - terminalRows);
52
+ const rows = Math.max(mainLines.length, viewportStart + sidebarLines.length);
53
+ const result = [...mainLines];
54
+ while (result.length < rows) result.push("");
55
+
56
+ for (let row = 0; row < sidebarLines.length; row++) {
57
+ const index = viewportStart + row;
58
+ result[index] = compositeTuiLine(
59
+ result[index] ?? "",
60
+ sidebarLines[row]!,
61
+ mainWidth + RESERVED_WIDTH - SIDEBAR_WIDTH,
62
+ SIDEBAR_WIDTH,
63
+ width,
64
+ );
65
+ }
66
+ return result;
67
+ }
68
+
69
+ function installRegularLayout(sidebar: Component): () => void {
70
+ const prototype = TuiMainScreen.prototype as TuiMainScreen & {
71
+ [REGULAR_PATCH_KEY]?: RegularPatchState;
72
+ };
73
+ const existing = prototype[REGULAR_PATCH_KEY];
74
+ if (existing) {
75
+ existing.refs++;
76
+ existing.sidebar = sidebar;
77
+ return () => uninstallRegularLayout(prototype, existing);
78
+ }
79
+
80
+ const originalRender = prototype.render;
81
+ const state: RegularPatchState = { originalRender, refs: 1, sidebar };
82
+ prototype[REGULAR_PATCH_KEY] = state;
83
+ prototype.render = function renderWithSidebar(width: number): string[] {
84
+ return renderRegularLayout(this, state, width);
85
+ };
86
+ return () => uninstallRegularLayout(prototype, state);
87
+ }
88
+
89
+ function uninstallRegularLayout(
90
+ prototype: TuiMainScreen & { [REGULAR_PATCH_KEY]?: RegularPatchState },
91
+ state: RegularPatchState,
92
+ ): void {
93
+ state.refs--;
94
+ if (state.refs > 0) return;
95
+ delete (prototype as any).render;
96
+ delete prototype[REGULAR_PATCH_KEY];
97
+ }
98
+
99
+ function wrapFullscreenRoot(root: Component, sidebar: Component, tui: TUI): Component {
100
+ return new HStack([
101
+ { component: root, basis: 0, grow: 1, shrink: 1, minSize: MIN_MAIN_WIDTH },
102
+ {
103
+ component: sidebar,
104
+ basis: SIDEBAR_WIDTH,
105
+ grow: 0,
106
+ shrink: 0,
107
+ minSize: SIDEBAR_WIDTH,
108
+ maxSize: SIDEBAR_WIDTH,
109
+ visible: ({ width }) => defocusHiddenSidebar(tui, sidebar, width),
110
+ },
111
+ ], { gap: RESERVED_WIDTH - SIDEBAR_WIDTH });
112
+ }
113
+
114
+ function installFullscreenLayout(tui: TUI, sidebar: Component): () => void {
115
+ const prototype = TuiAltScreen.prototype as TuiAltScreen & {
116
+ [FULLSCREEN_PATCH_KEY]?: FullscreenPatchState;
117
+ };
118
+ const existing = prototype[FULLSCREEN_PATCH_KEY];
119
+ if (existing) {
120
+ existing.refs++;
121
+ existing.sidebar = sidebar;
122
+ reapplyCurrentFullscreenRoot(tui, existing);
123
+ return () => uninstallFullscreenLayout(tui, prototype, existing);
124
+ }
125
+
126
+ const originalSetLayoutRoot = Object.getOwnPropertyDescriptor(TuiAltScreen.prototype, "setLayoutRoot")?.value as
127
+ | FullscreenPatchState["originalSetLayoutRoot"]
128
+ | undefined;
129
+ if (!originalSetLayoutRoot) throw new Error("Pi fullscreen layout API is unavailable");
130
+ const state: FullscreenPatchState = {
131
+ originalSetLayoutRoot,
132
+ refs: 1,
133
+ sidebar,
134
+ roots: new WeakMap(),
135
+ };
136
+ prototype[FULLSCREEN_PATCH_KEY] = state;
137
+ prototype.setLayoutRoot = function setLayoutRootWithSidebar(root: Component | undefined): void {
138
+ if (!root) {
139
+ state.originalSetLayoutRoot.call(this, undefined);
140
+ return;
141
+ }
142
+ state.latestRoot = root;
143
+ state.roots.set(this, root);
144
+ state.originalSetLayoutRoot.call(this, wrapFullscreenRoot(root, state.sidebar, this));
145
+ };
146
+ reapplyCurrentFullscreenRoot(tui, state);
147
+ return () => uninstallFullscreenLayout(tui, prototype, state);
148
+ }
149
+
150
+ function reapplyCurrentFullscreenRoot(tui: TUI, state: FullscreenPatchState): void {
151
+ if (!isViewportTUI(tui)) return;
152
+ const root = state.roots.get(tui as object) ?? ((tui as any).layoutRoot as Component | undefined);
153
+ if (root) (tui as TuiAltScreen).setLayoutRoot(root);
154
+ }
155
+
156
+ function uninstallFullscreenLayout(
157
+ tui: TUI,
158
+ prototype: TuiAltScreen & { [FULLSCREEN_PATCH_KEY]?: FullscreenPatchState },
159
+ state: FullscreenPatchState,
160
+ ): void {
161
+ state.refs--;
162
+ if (state.refs > 0) return;
163
+ if (isViewportTUI(tui) && state.latestRoot) {
164
+ state.originalSetLayoutRoot.call(tui, state.latestRoot);
165
+ }
166
+ prototype.setLayoutRoot = state.originalSetLayoutRoot;
167
+ delete prototype[FULLSCREEN_PATCH_KEY];
168
+ }
169
+
170
+ export class SidebarLayoutBridge implements Component {
171
+ private readonly uninstallRegular: () => void;
172
+ private readonly uninstallFullscreen: () => void;
173
+
174
+ constructor(private readonly tui: TUI, sidebar: Component) {
175
+ this.uninstallRegular = installRegularLayout(sidebar);
176
+ this.uninstallFullscreen = installFullscreenLayout(tui, sidebar);
177
+ tui.requestRender(true);
178
+ }
179
+
180
+ render(): string[] { return []; }
181
+ invalidate(): void {}
182
+ dispose(): void {
183
+ this.uninstallFullscreen();
184
+ this.uninstallRegular();
185
+ this.tui.requestRender(true);
186
+ }
187
+ }
188
+
189
+ export function assertLinesFit(lines: readonly string[], width: number, label: string): void {
190
+ for (const [index, line] of lines.entries()) {
191
+ const measured = visibleWidth(line);
192
+ if (measured > width) {
193
+ throw new Error(`${label} line ${index} exceeds width (${measured} > ${width})`);
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,221 @@
1
+ import {
2
+ copyToClipboard,
3
+ type ExtensionContext,
4
+ type ReadonlyFooterDataProvider,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { basename } from "node:path";
7
+ import type { Component, TUI } from "@earendil-works/pi-tui";
8
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { GAP_WINDOW, PINNED_COUNT, SIDEBAR_WIDTH } from "./constants.ts";
10
+ import { assertLinesFit } from "./layout.ts";
11
+ import { renderStatusDock } from "./status-dock.ts";
12
+ import {
13
+ BG,
14
+ BG_HDR,
15
+ BG_SEL,
16
+ BOLD,
17
+ DIM,
18
+ FG_ACC,
19
+ FG_BRIGHT,
20
+ FG_DIM,
21
+ FG_EXP,
22
+ FG_FAINT,
23
+ FG_MID,
24
+ FG_NORM,
25
+ FG_TIME,
26
+ RST,
27
+ fillRow,
28
+ formatTime,
29
+ wrapText,
30
+ } from "./style.ts";
31
+
32
+ export type UserMessage = { id: string; text: string; index: number; timestamp: string };
33
+
34
+ type SidebarOptions = {
35
+ tui: TUI;
36
+ ctx: ExtensionContext;
37
+ getFooterData: () => ReadonlyFooterDataProvider | null;
38
+ getThinkingLevel: () => string;
39
+ messages: UserMessage[];
40
+ };
41
+
42
+ export class SidebarComponent {
43
+ private focused = false;
44
+ private selected = 0;
45
+ private expanded = new Set<number>();
46
+ private messages: UserMessage[];
47
+ private version = 0;
48
+ private restoreFocus: Component | null = null;
49
+ private cachedSignature = "";
50
+ private cachedLines: string[] = [];
51
+
52
+ constructor(private readonly options: SidebarOptions) {
53
+ this.messages = options.messages;
54
+ this.selected = Math.max(0, this.messages.length - 1);
55
+ }
56
+
57
+ isFocused(): boolean { return this.focused; }
58
+
59
+ setFocused(focused: boolean): void {
60
+ if (this.focused === focused) return;
61
+ if (focused) {
62
+ this.restoreFocus = (this.options.tui as any).getFocusedComponent?.() ?? null;
63
+ this.focused = true;
64
+ this.options.tui.setFocus(this);
65
+ } else {
66
+ this.focused = false;
67
+ if ((this.options.tui as any).getFocusedComponent?.() === this) {
68
+ this.options.tui.setFocus(this.restoreFocus);
69
+ }
70
+ this.restoreFocus = null;
71
+ }
72
+ this.refresh();
73
+ }
74
+
75
+ updateMessages(messages: UserMessage[]): void {
76
+ this.messages = messages;
77
+ this.selected = Math.min(this.selected, Math.max(0, messages.length - 1));
78
+ this.refresh();
79
+ }
80
+
81
+ refresh(): void {
82
+ this.version++;
83
+ this.options.tui.requestRender();
84
+ }
85
+
86
+ handleInput(data: string): void {
87
+ if (matchesKey(data, "escape")) return this.setFocused(false);
88
+ if (matchesKey(data, "c")) { void this.copySessionPath(); return; }
89
+ if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
90
+ else if (matchesKey(data, "down")) this.selected = Math.min(this.messages.length - 1, this.selected + 1);
91
+ else if (matchesKey(data, "pageUp")) this.selected = Math.max(0, this.selected - 10);
92
+ else if (matchesKey(data, "pageDown")) this.selected = Math.min(this.messages.length - 1, this.selected + 10);
93
+ else if (matchesKey(data, "home")) this.selected = 0;
94
+ else if (matchesKey(data, "end")) this.selected = Math.max(0, this.messages.length - 1);
95
+ else if (matchesKey(data, "return") || matchesKey(data, "enter") || data === " ") {
96
+ if (this.expanded.has(this.selected)) this.expanded.delete(this.selected);
97
+ else this.expanded.add(this.selected);
98
+ } else return;
99
+ this.refresh();
100
+ }
101
+
102
+ render(width: number): string[] {
103
+ const safeWidth = Math.max(1, Math.min(SIDEBAR_WIDTH, width));
104
+ const targetHeight = Math.max(15, this.options.tui.terminal.rows);
105
+ const signature = this.signature(safeWidth, targetHeight);
106
+ if (signature === this.cachedSignature) return this.cachedLines;
107
+
108
+ const lines = this.renderHeader(safeWidth);
109
+ if (this.messages.length === 0) {
110
+ lines.push(fillRow(" No messages yet", safeWidth, BG));
111
+ } else {
112
+ lines.push(...this.renderMessages(safeWidth));
113
+ }
114
+
115
+ const dock = renderStatusDock(
116
+ safeWidth,
117
+ this.options.ctx,
118
+ this.options.getFooterData(),
119
+ this.options.getThinkingLevel(),
120
+ this.focused,
121
+ );
122
+ while (lines.length < targetHeight - dock.length) lines.push(fillRow(" ", safeWidth, BG));
123
+ const bodyLimit = Math.max(0, targetHeight - dock.length);
124
+ const result = [...lines.slice(0, bodyLimit), ...dock].slice(0, targetHeight);
125
+ assertLinesFit(result, safeWidth, "sidebar");
126
+ this.cachedSignature = signature;
127
+ this.cachedLines = result;
128
+ return result;
129
+ }
130
+
131
+ invalidate(): void {
132
+ this.cachedSignature = "";
133
+ this.cachedLines = [];
134
+ }
135
+
136
+ private renderHeader(width: number): string[] {
137
+ const icon = this.focused ? `${FG_ACC}●${RST}` : `${FG_DIM}○${RST}`;
138
+ const mode = this.focused ? `${FG_ACC}●${RST} ${FG_MID}focused${RST}` : `${FG_DIM}passive${RST}`;
139
+ const separator = `${FG_DIM}${"─".repeat(Math.max(0, width - 4))}${RST}`;
140
+ return [
141
+ fillRow(" ", width, BG_HDR),
142
+ fillRow(` ${icon} ${BOLD}${FG_BRIGHT}Messages${RST}${FG_DIM} ${this.messages.length}${RST} ${mode}`, width, BG_HDR),
143
+ fillRow(" ", width, BG_HDR),
144
+ fillRow(` ${separator}`, width, BG),
145
+ ];
146
+ }
147
+
148
+ private renderMessages(width: number): string[] {
149
+ const result: string[] = [];
150
+ let previous = -1;
151
+ for (const index of this.visibleIndices()) {
152
+ if (index > previous + 1 && previous >= 0) {
153
+ result.push(fillRow(` ${FG_DIM}··· ${index - previous - 1} more ···${RST}`, width, BG));
154
+ }
155
+ result.push(...this.renderMessage(index, width));
156
+ previous = index;
157
+ }
158
+ return result;
159
+ }
160
+
161
+ private renderMessage(index: number, width: number): string[] {
162
+ const message = this.messages[index]!;
163
+ const selected = index === this.selected;
164
+ const background = selected ? BG_SEL : BG;
165
+ const arrow = selected && this.focused ? `${FG_ACC}▸${RST}` : " ";
166
+ const number = `${FG_FAINT}${String(message.index).padStart(2)}${RST}`;
167
+ const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
168
+ if (!this.expanded.has(index)) {
169
+ const prefix = ` ${arrow}${number} ${time} `;
170
+ const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
171
+ return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
172
+ }
173
+
174
+ const lines = [fillRow(` ${arrow}${number} ${time}`, width, background)];
175
+ const wrapped = wrapText(message.text, Math.max(1, width - 4));
176
+ for (const line of wrapped.slice(0, 8)) lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
177
+ if (wrapped.length > 8) {
178
+ lines.push(fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - 8} lines${RST}`, width, background));
179
+ }
180
+ lines.push(fillRow(" ", width, background));
181
+ return lines;
182
+ }
183
+
184
+ private visibleIndices(): number[] {
185
+ const total = this.messages.length;
186
+ if (total <= PINNED_COUNT * 2 + 1) return Array.from({ length: total }, (_, index) => index);
187
+ const indices = new Set<number>();
188
+ for (let i = 0; i < PINNED_COUNT; i++) indices.add(i);
189
+ for (let i = total - PINNED_COUNT; i < total; i++) indices.add(i);
190
+ if (this.selected >= PINNED_COUNT && this.selected < total - PINNED_COUNT) {
191
+ const start = Math.max(PINNED_COUNT, Math.min(this.selected - 1, total - PINNED_COUNT - GAP_WINDOW));
192
+ for (let i = start; i < start + GAP_WINDOW; i++) indices.add(i);
193
+ }
194
+ return [...indices].sort((a, b) => a - b);
195
+ }
196
+
197
+ private signature(width: number, height: number): string {
198
+ const usage = this.options.ctx.getContextUsage?.();
199
+ const statuses = this.options.getFooterData()?.getExtensionStatuses();
200
+ return JSON.stringify({
201
+ width,
202
+ height,
203
+ version: this.version,
204
+ messages: this.messages.length,
205
+ model: this.options.ctx.model?.id,
206
+ thinking: this.options.getThinkingLevel(),
207
+ usage,
208
+ statuses: statuses ? [...statuses.entries()] : [],
209
+ });
210
+ }
211
+
212
+ private async copySessionPath(): Promise<void> {
213
+ const target = this.options.ctx.sessionManager.getSessionFile() ?? this.options.ctx.sessionManager.getSessionId();
214
+ try {
215
+ await copyToClipboard(target);
216
+ this.options.ctx.ui.notify(`Copied ${basename(target)}`, "info");
217
+ } catch {
218
+ this.options.ctx.ui.notify(`Session path: ${target}`, "warning");
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,154 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ReadonlyFooterDataProvider,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { basename } from "node:path";
6
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import {
8
+ BG,
9
+ BG_CARD,
10
+ BG_HDR,
11
+ FG_BRIGHT,
12
+ FG_DIM,
13
+ FG_FAINT,
14
+ FG_INFO,
15
+ FG_MID,
16
+ RST,
17
+ contextColor,
18
+ fillRow,
19
+ formatCwd,
20
+ formatTokens,
21
+ progressBar,
22
+ sanitizeStatusText,
23
+ } from "./style.ts";
24
+
25
+ type Usage = {
26
+ input: number;
27
+ output: number;
28
+ cacheRead: number;
29
+ cacheWrite: number;
30
+ cost: number;
31
+ latestCacheHitRate?: number;
32
+ contextPercent: number | null;
33
+ contextWindow: number;
34
+ };
35
+
36
+ function computeUsage(ctx: ExtensionContext): Usage {
37
+ let input = 0;
38
+ let output = 0;
39
+ let cacheRead = 0;
40
+ let cacheWrite = 0;
41
+ let cost = 0;
42
+ let latestCacheHitRate: number | undefined;
43
+
44
+ for (const entry of ctx.sessionManager.getEntries()) {
45
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
46
+ const usage = (entry.message as any).usage ?? {};
47
+ input += usage.input ?? 0;
48
+ output += usage.output ?? 0;
49
+ cacheRead += usage.cacheRead ?? 0;
50
+ cacheWrite += usage.cacheWrite ?? 0;
51
+ cost += usage.cost?.total ?? 0;
52
+ const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
53
+ latestCacheHitRate = prompt > 0 ? ((usage.cacheRead ?? 0) / prompt) * 100 : undefined;
54
+ }
55
+
56
+ const context = ctx.getContextUsage?.();
57
+ return {
58
+ input,
59
+ output,
60
+ cacheRead,
61
+ cacheWrite,
62
+ cost,
63
+ latestCacheHitRate,
64
+ contextPercent: context?.percent ?? null,
65
+ contextWindow: context?.contextWindow ?? ctx.model?.contextWindow ?? 0,
66
+ };
67
+ }
68
+
69
+ function dockHeader(width: number, title: string): string {
70
+ const pad = " ";
71
+ const ruleWidth = Math.max(0, width - visibleWidth(pad) - visibleWidth(title) - 1);
72
+ return fillRow(`${pad}${FG_FAINT}${title}${RST} ${FG_FAINT}${"─".repeat(ruleWidth)}${RST}`, width, BG);
73
+ }
74
+
75
+ function dockRow(width: number, label: string, value: string): string {
76
+ const pad = " ";
77
+ const labelWidth = 5;
78
+ const maxValueWidth = Math.max(0, width - visibleWidth(pad) - labelWidth - 1);
79
+ const clipped = truncateToWidth(value, maxValueWidth, "…");
80
+ return fillRow(`${pad}${FG_FAINT}${label.padEnd(labelWidth)}${RST} ${clipped}`, width, BG_CARD);
81
+ }
82
+
83
+ export function renderStatusDock(
84
+ width: number,
85
+ ctx: ExtensionContext,
86
+ footerData: ReadonlyFooterDataProvider | null,
87
+ thinkingLevel: string,
88
+ focused: boolean,
89
+ ): string[] {
90
+ const usage = computeUsage(ctx);
91
+ const rows = [fillRow(" ", width, BG), dockHeader(width, "runtime")];
92
+ const branch = footerData?.getGitBranch();
93
+ const sessionName = ctx.sessionManager.getSessionName();
94
+ const workspace = [
95
+ `${FG_BRIGHT}${formatCwd(ctx.sessionManager.getCwd())}${RST}`,
96
+ branch ? `${FG_INFO}${branch}${RST}` : undefined,
97
+ sessionName ? `${FG_MID}${sessionName}${RST}` : undefined,
98
+ ].filter(Boolean).join(` ${FG_FAINT}•${RST} `);
99
+ rows.push(dockRow(width, "cwd", workspace));
100
+
101
+ const sessionId = ctx.sessionManager.getSessionId();
102
+ const sessionFile = ctx.sessionManager.getSessionFile();
103
+ const shortId = sessionId.replace(/-/g, "").slice(-8);
104
+ rows.push(dockRow(width, "sess", [
105
+ `${FG_INFO}#${shortId}${RST}`,
106
+ sessionFile ? `${FG_FAINT}${basename(sessionFile)}${RST}` : undefined,
107
+ ].filter(Boolean).join(` ${FG_FAINT}•${RST} `)));
108
+
109
+ const model = ctx.model;
110
+ if (model) {
111
+ const provider = footerData && footerData.getAvailableProviderCount() > 1
112
+ ? `${FG_FAINT}${model.provider}${RST} `
113
+ : "";
114
+ const thinking = model.reasoning ? ` ${FG_FAINT}•${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
115
+ rows.push(dockRow(width, "model", `${provider}${FG_BRIGHT}${model.id}${RST}${thinking}`));
116
+ }
117
+
118
+ const contextDisplay = usage.contextPercent === null
119
+ ? `?/${formatTokens(usage.contextWindow)}`
120
+ : `${usage.contextPercent.toFixed(1)}%/${formatTokens(usage.contextWindow)}`;
121
+ const usingSubscription = model ? Boolean((ctx.modelRegistry as any).isUsingOAuth?.(model)) : false;
122
+ const tokenParts = [
123
+ usage.input ? `↑${formatTokens(usage.input)}` : undefined,
124
+ usage.output ? `↓${formatTokens(usage.output)}` : undefined,
125
+ usage.cacheRead ? `R${formatTokens(usage.cacheRead)}` : undefined,
126
+ usage.cacheWrite ? `W${formatTokens(usage.cacheWrite)}` : undefined,
127
+ usage.latestCacheHitRate !== undefined && (usage.cacheRead || usage.cacheWrite)
128
+ ? `CH${usage.latestCacheHitRate.toFixed(1)}%`
129
+ : undefined,
130
+ ].filter(Boolean).join(" ");
131
+ rows.push(dockRow(
132
+ width,
133
+ "ctx",
134
+ `${contextColor(usage.contextPercent)}${contextDisplay}${RST} ${progressBar(usage.contextPercent)}`,
135
+ ));
136
+ rows.push(dockRow(
137
+ width,
138
+ "use",
139
+ `${FG_BRIGHT}$${usage.cost.toFixed(3)}${usingSubscription ? " sub" : ""}${RST}` +
140
+ (tokenParts ? ` ${FG_FAINT}•${RST} ${FG_MID}${tokenParts}${RST}` : ` ${FG_FAINT}• no token usage${RST}`),
141
+ ));
142
+
143
+ const statuses = footerData ? [...footerData.getExtensionStatuses().entries()].sort(([a], [b]) => a.localeCompare(b)) : [];
144
+ for (const [, text] of statuses.slice(0, 2)) {
145
+ rows.push(dockRow(width, "stat", `${FG_INFO}•${RST} ${FG_MID}${sanitizeStatusText(text)}${RST}`));
146
+ }
147
+
148
+ rows.push(fillRow(" ", width, BG));
149
+ const hint = focused
150
+ ? `${FG_DIM}↑↓${RST} ${FG_MID}nav${RST} ${FG_FAINT}·${RST} ${FG_DIM}Enter${RST} ${FG_MID}expand${RST} ${FG_FAINT}·${RST} ${FG_DIM}c${RST} ${FG_MID}copy${RST} ${FG_FAINT}·${RST} ${FG_DIM}Esc${RST} ${FG_MID}done${RST}`
151
+ : `${FG_DIM}Ctrl+Shift+H${RST} ${FG_MID}focus${RST}`;
152
+ rows.push(fillRow(` ${hint}`, width, BG_HDR));
153
+ return rows;
154
+ }
package/src/style.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { basename } from "node:path";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+
4
+ export const BG = "\x1b[48;5;232m";
5
+ export const BG_SEL = "\x1b[48;5;235m";
6
+ export const BG_HDR = "\x1b[48;5;233m";
7
+ export const BG_CARD = "\x1b[48;5;234m";
8
+ export const FG_FAINT = "\x1b[38;5;240m";
9
+ export const FG_DIM = "\x1b[38;5;243m";
10
+ export const FG_MID = "\x1b[38;5;248m";
11
+ export const FG_NORM = "\x1b[38;5;250m";
12
+ export const FG_BRIGHT = "\x1b[38;5;255m";
13
+ export const FG_ACC = "\x1b[38;5;75m";
14
+ export const FG_INFO = "\x1b[38;5;80m";
15
+ export const FG_OK = "\x1b[38;5;114m";
16
+ export const FG_WARN = "\x1b[38;5;215m";
17
+ export const FG_ERR = "\x1b[38;5;203m";
18
+ export const FG_TIME = "\x1b[38;5;242m";
19
+ export const FG_EXP = "\x1b[38;5;252m";
20
+ export const BOLD = "\x1b[1m";
21
+ export const DIM = "\x1b[2m";
22
+ export const RST = "\x1b[0m";
23
+
24
+ export function fillRow(content: string, width: number, bg: string): string {
25
+ const safeWidth = Math.max(0, Math.floor(width));
26
+ if (safeWidth === 0) return "";
27
+ const injected = content.replace(/\x1b\[0m/g, `${RST}${bg}`);
28
+ return `${bg}${truncateToWidth(injected, safeWidth, "", true)}${RST}`;
29
+ }
30
+
31
+ export function wrapText(text: string, width: number): string[] {
32
+ return wrapTextWithAnsi(text.replace(/\s+/g, " ").trim(), Math.max(1, width));
33
+ }
34
+
35
+ export function formatTime(timestamp: string): string {
36
+ const date = new Date(timestamp);
37
+ if (Number.isNaN(date.valueOf())) return "";
38
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
39
+ }
40
+
41
+ export function sanitizeStatusText(text: string): string {
42
+ return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
43
+ }
44
+
45
+ export function formatTokens(count: number): string {
46
+ if (!count) return "0";
47
+ if (count < 1_000) return String(count);
48
+ if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
49
+ if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
50
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
51
+ return `${Math.round(count / 1_000_000)}M`;
52
+ }
53
+
54
+ export function contextColor(percent: number | null): string {
55
+ if (percent === null) return FG_DIM;
56
+ if (percent > 90) return FG_ERR;
57
+ if (percent > 70) return FG_WARN;
58
+ return FG_OK;
59
+ }
60
+
61
+ export function progressBar(percent: number | null, width = 10): string {
62
+ if (percent === null) return `${FG_DIM}${"░".repeat(width)}${RST}`;
63
+ const clamped = Math.max(0, Math.min(100, percent));
64
+ const filled = Math.round((clamped / 100) * width);
65
+ return `${contextColor(percent)}${"█".repeat(filled)}${FG_DIM}${"░".repeat(width - filled)}${RST}`;
66
+ }
67
+
68
+ export function formatCwd(cwd: string): string {
69
+ const home = process.env.HOME || process.env.USERPROFILE || "";
70
+ if (home && cwd === home) return "~";
71
+ if (home && cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
72
+ return basename(cwd) || cwd;
73
+ }
74
+
75
+ export function contentWidth(width: number, prefix: string, suffix = 0): number {
76
+ return Math.max(0, width - visibleWidth(prefix) - suffix);
77
+ }