pi-context-inspector 1.1.1 → 1.3.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/README.md CHANGED
@@ -9,7 +9,7 @@ Opens a tabbed overlay with the full LLM context of the current session in [pi-c
9
9
  - **One command, five views.** `/context` opens a centered overlay with Stats, System, Tools, Messages and Full tabs.
10
10
  - **Token breakdown that matches reality.** Raw character-based estimates are scaled to the provider-reported token count, so category percentages are proportional to the real usage — not to a guess.
11
11
  - **A visual usage grid.** A 10×5 colored grid (50 blocks, 2% each) shows at a glance how much of the context window is system prompt, tools, skills, messages, available space, and the auto-compact buffer.
12
- - **Scroll, search, copy.** Every content tab supports vim-style scrolling, live `/` search with match navigation, and `y` to copy the raw text to the clipboard.
12
+ - **Scroll, search, copy, or inspect in an editor.** Every content tab supports vim style scrolling, live `/` search with match navigation, `y` to copy the raw text, and `e` to open a snapshot in `$EDITOR`.
13
13
  - **Skill-aware accounting.** Tool calls that read skill files (`.agents/skills/`, `.pi/agent/*/skills/`, `skills/*/SKILL.md`) are counted under Skills instead of Tools.
14
14
 
15
15
  ## Quick start
@@ -20,7 +20,7 @@ Run `/context` at any point during a session:
20
20
  /context
21
21
  ```
22
22
 
23
- The overlay opens with the Stats tab active. `Tab` / `Shift+Tab` cycles tabs, `q` or `Escape` closes the overlay.
23
+ The overlay opens with the Stats tab active. `Tab` / `Shift+Tab` cycles tabs, `q` or `Escape` closes the overlay. On System, Tools, Messages, or Full, press `e` to open that view in `$EDITOR`. Pi resumes when the editor exits. Any edits to the temporary file are discarded and do not change the session. The editor removes color codes from tool results in Messages and Full; the overlay and copied text keep the original content. Stats and Skills do not have editor views; Skills is a category in Stats, not a separate tab.
24
24
 
25
25
  ## Installation
26
26
 
@@ -55,6 +55,7 @@ pi install /path/to/pi-context-inspector
55
55
  | `/` | Live search (type, `Enter` to commit) |
56
56
  | `n` / `N` | Next / previous match |
57
57
  | `y` | Copy the tab's raw text to the clipboard |
58
+ | `e` | Open the current text tab in `$EDITOR` |
58
59
  | `q` / `Escape` | Close the overlay |
59
60
 
60
61
  ## How the token breakdown works
@@ -65,6 +66,7 @@ Each category is estimated from raw text (chars ÷ 4, using pi's own per-message
65
66
 
66
67
  - **"No context usage data available."** Send a message first, then re-open `/context` — usage is only reported once a turn has run.
67
68
  - **The overlay doesn't open.** `/context` requires interactive (TUI) mode; it is a no-op when `ctx.hasUI` is false.
69
+ - **The editor doesn't open.** Set `$EDITOR` to a terminal editor command, such as `vim` or `code --wait`. The command must stay open until you finish viewing the file.
68
70
  - **Percentages look off.** The breakdown scales estimates to the provider's reported total, so category sizes are proportional — but the provider total itself is only as accurate as the provider's usage reporting.
69
71
 
70
72
  ## Development
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-context-inspector",
3
3
  "author": "yuriteixeira",
4
- "version": "1.1.1",
4
+ "version": "1.3.0",
5
5
  "type": "module",
6
6
  "description": "Pi extension: inspect the system prompt, active tools, messages, token breakdown and full LLM context in a tabbed overlay",
7
7
  "main": "index.ts",
@@ -39,11 +39,11 @@
39
39
  "node": ">=22.19.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@earendil-works/pi-coding-agent": "^0.84.0",
43
- "@types/node": "^24.0.0",
44
- "typescript": "~5.9.3",
45
- "vitest": "^4.1.9",
46
- "@earendil-works/pi-tui": "^0.84.0"
42
+ "@earendil-works/pi-coding-agent": "^0.87.0",
43
+ "@earendil-works/pi-tui": "^0.87.0",
44
+ "@types/node": "^26.6.2",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^5.0.1"
47
47
  },
48
48
  "scripts": {
49
49
  "test": "vitest run",
@@ -0,0 +1,42 @@
1
+ import type { SessionContext } from "@earendil-works/pi-coding-agent";
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildTotalContextText, formatMessagesText } from "./index.js";
4
+
5
+ const red = "\x1b[31m";
6
+ const trueColor = "\x1b[38;2;255;0;0m";
7
+ const reset = "\x1b[0m";
8
+ const context = {
9
+ messages: [
10
+ { role: "user", content: `${red}user${reset}` },
11
+ {
12
+ role: "toolResult",
13
+ toolName: "bash",
14
+ toolCallId: "1",
15
+ isError: false,
16
+ content: [
17
+ { type: "text", text: `${red}error${reset}\n${trueColor}detail${reset}` },
18
+ { type: "image", mimeType: "image/png" },
19
+ ],
20
+ },
21
+ ],
22
+ } as SessionContext;
23
+
24
+ describe("editor tool result colors", () => {
25
+ it("removes color codes only from tool results in the editor message view", () => {
26
+ const overlay = formatMessagesText(context);
27
+ const editor = formatMessagesText(context, true);
28
+ expect(overlay).toContain(`${red}error${reset}`);
29
+ expect(overlay).toContain(`${trueColor}detail${reset}`);
30
+ expect(editor).toContain("error\ndetail");
31
+ expect(editor).not.toContain(`${red}error`);
32
+ expect(editor).toContain(`${red}user${reset}`);
33
+ expect(editor).toContain("[Image: image/png]");
34
+ });
35
+
36
+ it("removes tool result colors in the editor full view without changing the system prompt", () => {
37
+ const full = buildTotalContextText(`${red}system${reset}`, context, undefined, undefined, true);
38
+ expect(full).toContain(`${red}system${reset}`);
39
+ expect(full).toContain("error\ndetail");
40
+ expect(full).not.toContain(`${trueColor}detail`);
41
+ });
42
+ });
@@ -0,0 +1,49 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { EventEmitter } from "node:events";
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { openInExternalEditor } from "./external-editor.js";
6
+
7
+ const spawn = vi.hoisted(() => vi.fn());
8
+ vi.mock("node:child_process", () => ({ spawn }));
9
+
10
+ afterEach(() => {
11
+ vi.unstubAllEnvs();
12
+ spawn.mockReset();
13
+ });
14
+
15
+ describe("openInExternalEditor", () => {
16
+ it("opens the raw view, restores the terminal and removes the temporary file", async () => {
17
+ vi.stubEnv("EDITOR", "vim -R");
18
+ const events: string[] = [];
19
+ const tui = {
20
+ stop: () => events.push("stop"),
21
+ start: () => events.push("start"),
22
+ requestRender: () => events.push("render"),
23
+ } as unknown as TUI;
24
+ let path = "";
25
+ let content = "";
26
+ spawn.mockImplementation((_editor: string, args: string[]) => {
27
+ path = args.at(-1)!;
28
+ const child = new EventEmitter();
29
+ void readFile(path, "utf8").then((text) => {
30
+ content = text;
31
+ child.emit("close", 0);
32
+ });
33
+ return child;
34
+ });
35
+
36
+ await openInExternalEditor(tui, "Tools", "Tool: read\nParameters: {}\n");
37
+ expect(spawn).toHaveBeenCalledWith("vim", ["-R", path], expect.objectContaining({ stdio: "inherit" }));
38
+ expect(content).toBe("Tool: read\nParameters: {}\n");
39
+ expect(events).toEqual(["stop", "start", "render"]);
40
+ await expect(readFile(path, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
41
+ });
42
+
43
+ it("does not stop the terminal without an editor", async () => {
44
+ vi.stubEnv("EDITOR", "");
45
+ const stop = vi.fn();
46
+ await expect(openInExternalEditor({ stop } as unknown as TUI, "Full", "text")).rejects.toThrow("Set $EDITOR");
47
+ expect(stop).not.toHaveBeenCalled();
48
+ });
49
+ });
@@ -0,0 +1,32 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { TUI } from "@earendil-works/pi-tui";
6
+
7
+ /** Show a snapshot in an external editor. Changes to the file do not change the session. */
8
+ export async function openInExternalEditor(tui: TUI, name: string, text: string): Promise<void> {
9
+ const command = process.env.EDITOR?.trim();
10
+ if (!command) throw new Error("Set $EDITOR to open a context view in an editor.");
11
+
12
+ const directory = await mkdtemp(join(tmpdir(), "pi-context-"));
13
+ const filePath = join(directory, `${name.toLowerCase()}.txt`);
14
+ try {
15
+ await writeFile(filePath, text, { encoding: "utf8", mode: 0o600 });
16
+ const [editor, ...args] = command.split(/\s+/);
17
+ tui.stop();
18
+ try {
19
+ const exitCode = await new Promise<number>((resolve, reject) => {
20
+ const child = spawn(editor!, [...args, filePath], { stdio: "inherit", shell: process.platform === "win32" });
21
+ child.once("error", reject);
22
+ child.once("close", (code) => resolve(code ?? 1));
23
+ });
24
+ if (exitCode !== 0) throw new Error(`Editor exited with code ${exitCode}.`);
25
+ } finally {
26
+ tui.start();
27
+ tui.requestRender(true);
28
+ }
29
+ } finally {
30
+ await rm(directory, { recursive: true, force: true });
31
+ }
32
+ }
package/src/index.ts CHANGED
@@ -26,9 +26,10 @@ import {
26
26
  type Theme,
27
27
  type ToolInfo,
28
28
  } from "@earendil-works/pi-coding-agent";
29
+ import { openInExternalEditor } from "./external-editor.js";
29
30
  import { ScrollableTabContent } from "./scrollable-tab-content.js";
30
31
  import { type ContextTokenBreakdown, StatsTabContent } from "./stats-tab-content.js";
31
- import { TabbedOverlay } from "./tabbed-overlay.js";
32
+ import { OVERLAY_HEIGHT_PERCENT, TabbedOverlay } from "./tabbed-overlay.js";
32
33
  import { formatTokens } from "./utils.js";
33
34
 
34
35
  type AgentMessage = SessionContext["messages"][number];
@@ -48,21 +49,25 @@ export function buildNumberedLines(text: string, theme: Theme): string[] {
48
49
  });
49
50
  }
50
51
 
51
- function numberedTab(text: string, name: string, theme: Theme): ScrollableTabContent {
52
+ function numberedTab(text: string, name: string, theme: Theme, onOpenEditor: () => void): ScrollableTabContent {
52
53
  return new ScrollableTabContent(
53
- { rawText: text, displayLines: buildNumberedLines(text, theme), theme },
54
+ { rawText: text, displayLines: buildNumberedLines(text, theme), theme, onOpenEditor },
54
55
  name,
55
56
  );
56
57
  }
57
58
 
58
- function formatContent(content: MessageContent): string[] {
59
+ function stripColorSequences(text: string): string {
60
+ return text.replace(/\x1b\[[0-9;:]*m/g, "");
61
+ }
62
+
63
+ function formatContent(content: MessageContent, stripColors = false): string[] {
59
64
  if (typeof content === "string") return [content];
60
65
 
61
66
  const lines: string[] = [];
62
67
  for (const block of content) {
63
68
  switch (block.type) {
64
69
  case "text":
65
- lines.push(block.text);
70
+ lines.push(stripColors ? stripColorSequences(block.text) : block.text);
66
71
  break;
67
72
  case "thinking":
68
73
  lines.push(`[Thinking: ${block.thinking}]`);
@@ -89,7 +94,7 @@ function formatUsage(usage: AssistantMessage["usage"]): string {
89
94
  parts.push(`total: ${usage.totalTokens}`);
90
95
  return `Tokens: ${parts.join(", ")}`;
91
96
  }
92
- export function formatMessageForDisplay(message: SessionContext["messages"][number], index: number): string[] {
97
+ export function formatMessageForDisplay(message: SessionContext["messages"][number], index: number, editorView = false): string[] {
93
98
  const lines: string[] = ["", `──── Message ${index + 1} ────`, `Role: ${message.role}`];
94
99
 
95
100
  if (message.role === "assistant") {
@@ -137,16 +142,16 @@ export function formatMessageForDisplay(message: SessionContext["messages"][numb
137
142
  }
138
143
 
139
144
  if ("content" in message) {
140
- lines.push(...formatContent(message.content));
145
+ lines.push(...formatContent(message.content, editorView && message.role === "toolResult"));
141
146
  }
142
147
  return lines;
143
148
  }
144
149
 
145
- export function formatMessagesText(context: SessionContext): string {
150
+ export function formatMessagesText(context: SessionContext, editorView = false): string {
146
151
  const lines: string[] = [];
147
152
  if (context.messages.length > 0) {
148
153
  for (let i = 0; i < context.messages.length; i++) {
149
- lines.push(...formatMessageForDisplay(context.messages[i]!, i));
154
+ lines.push(...formatMessageForDisplay(context.messages[i]!, i, editorView));
150
155
  }
151
156
  } else {
152
157
  lines.push("(no messages yet)");
@@ -165,6 +170,7 @@ export function buildTotalContextText(
165
170
  context: SessionContext,
166
171
  usage: ContextUsage | undefined,
167
172
  model: ContextViewerModelInfo | undefined,
173
+ editorView = false,
168
174
  ): string {
169
175
  const sections: string[] = [];
170
176
 
@@ -178,7 +184,7 @@ export function buildTotalContextText(
178
184
  sections.push("MESSAGES");
179
185
  sections.push("═══════════════════════════════════════════════════════");
180
186
 
181
- sections.push(formatMessagesText(context));
187
+ sections.push(formatMessagesText(context, editorView));
182
188
 
183
189
  sections.push("");
184
190
  sections.push("═══════════════════════════════════════════════════════");
@@ -367,7 +373,7 @@ const OVERLAY_OPTIONS = {
367
373
  anchor: "center" as const,
368
374
  width: "90%" as const,
369
375
  minWidth: 60,
370
- maxHeight: "90%" as const,
376
+ maxHeight: `${OVERLAY_HEIGHT_PERCENT}%` as const,
371
377
  },
372
378
  };
373
379
 
@@ -402,18 +408,30 @@ export default function contextViewerExtension(pi: ExtensionAPI): void {
402
408
  : "no usage data yet";
403
409
 
404
410
  // ── Build and open the overlay ──────────────────────────────────────
405
- await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
411
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
406
412
  const modelName = ctx.model?.id ?? "unknown model";
413
+ let editorOpen = false;
414
+ const editorAction = (name: string, text: string) => async () => {
415
+ if (editorOpen) return;
416
+ editorOpen = true;
417
+ try {
418
+ await openInExternalEditor(tui, name, text);
419
+ } catch (error) {
420
+ ctx.ui.notify(`Cannot open editor: ${error instanceof Error ? error.message : String(error)}`, "error");
421
+ } finally {
422
+ editorOpen = false;
423
+ }
424
+ };
407
425
 
408
426
  const tabs = [
409
427
  new StatsTabContent(breakdown, theme, {
410
428
  name: modelName,
411
429
  contextWindow: ctx.model?.contextWindow ?? usage?.contextWindow,
412
430
  }),
413
- numberedTab(systemPrompt, "System", theme),
414
- numberedTab(toolsText, "Tools", theme),
415
- numberedTab(messagesText, "Messages", theme),
416
- numberedTab(fullText, "Full", theme),
431
+ numberedTab(systemPrompt, "System", theme, editorAction("System", systemPrompt)),
432
+ numberedTab(toolsText, "Tools", theme, editorAction("Tools", toolsText)),
433
+ numberedTab(messagesText, "Messages", theme, editorAction("Messages", formatMessagesText(context, true))),
434
+ numberedTab(fullText, "Full", theme, editorAction("Full", buildTotalContextText(systemPrompt, context, usage, ctx.model, true))),
417
435
  ];
418
436
 
419
437
  return new TabbedOverlay({
@@ -422,7 +440,7 @@ export default function contextViewerExtension(pi: ExtensionAPI): void {
422
440
  tabs,
423
441
  theme,
424
442
  done,
425
- });
443
+ }, tui);
426
444
  }, OVERLAY_OPTIONS);
427
445
  },
428
446
  });
@@ -13,13 +13,14 @@ export abstract class ScrollableBase {
13
13
  protected visualLines: string[] = [];
14
14
  protected visualToLogical: number[] = [];
15
15
  protected visualTotal = 0;
16
+ protected visibleHeight = CONTENT_HEIGHT;
16
17
 
17
18
  protected abstract get rawText(): string;
18
19
  protected abstract get displayLines(): string[];
19
20
  protected abstract get theme(): Theme;
20
21
 
21
22
  protected getVisibleLines(): number {
22
- return CONTENT_HEIGHT;
23
+ return this.visibleHeight;
23
24
  }
24
25
 
25
26
  protected buildVisualLines(innerWidth: number): void {
@@ -121,11 +122,11 @@ export abstract class ScrollableBase {
121
122
  return true;
122
123
  }
123
124
  if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
124
- this.scrollDown(visibleLines - 2, maxOffset);
125
+ this.scrollDown(Math.max(1, visibleLines - 2), maxOffset);
125
126
  return true;
126
127
  }
127
128
  if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b"))) {
128
- this.scrollUp(visibleLines - 2);
129
+ this.scrollUp(Math.max(1, visibleLines - 2));
129
130
  return true;
130
131
  }
131
132
  if (matchesKey(data, Key.ctrl("d"))) {
@@ -0,0 +1,20 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ScrollableTabContent } from "./scrollable-tab-content.js";
4
+
5
+ describe("editor shortcut", () => {
6
+ it("opens the text tab but lets search accept the letter e", () => {
7
+ const onOpenEditor = vi.fn();
8
+ const tab = new ScrollableTabContent({
9
+ rawText: "example",
10
+ displayLines: ["example"],
11
+ theme: {} as Theme,
12
+ onOpenEditor,
13
+ }, "Tools");
14
+ expect(tab.handleInput("e")).toBe(true);
15
+ expect(onOpenEditor).toHaveBeenCalledOnce();
16
+ tab.handleInput("/");
17
+ tab.handleInput("e");
18
+ expect(onOpenEditor).toHaveBeenCalledOnce();
19
+ });
20
+ });
@@ -6,6 +6,7 @@ export interface ScrollableTabContentOptions {
6
6
  rawText: string;
7
7
  displayLines: string[];
8
8
  theme: Theme;
9
+ onOpenEditor?: () => void;
9
10
  }
10
11
 
11
12
  export class ScrollableTabContent extends ScrollableBase implements TabContent {
@@ -56,13 +57,21 @@ export class ScrollableTabContent extends ScrollableBase implements TabContent {
56
57
  return left;
57
58
  }
58
59
 
59
- readonly footerHints = "↑↓ scroll · / search · n/N next · y copy";
60
+ get footerHints(): string {
61
+ return `↑↓ scroll · / search · n/N next · y copy${this.opts.onOpenEditor ? " · e editor" : ""}`;
62
+ }
60
63
 
61
64
  handleInput(data: string): boolean {
62
- return this.handleScrollKey(data);
65
+ if (this.handleScrollKey(data)) return true;
66
+ if (data === "e" && this.opts.onOpenEditor) {
67
+ this.opts.onOpenEditor();
68
+ return true;
69
+ }
70
+ return false;
63
71
  }
64
72
 
65
73
  renderContent(innerWidth: number, height: number): string[] {
74
+ this.visibleHeight = height;
66
75
  this.buildVisualLines(innerWidth);
67
76
  const th = this.opts.theme;
68
77
  const lines: string[] = [];
@@ -0,0 +1,57 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import { describe, expect, it } from "vitest";
4
+ import { ScrollableTabContent } from "./scrollable-tab-content.js";
5
+ import { TabbedOverlay } from "./tabbed-overlay.js";
6
+
7
+ const theme = {
8
+ fg: (_color: string, text: string) => text,
9
+ bold: (text: string) => text,
10
+ } as Theme;
11
+
12
+ function createOverlay(rows: number) {
13
+ const terminal = { rows };
14
+ const tab = new ScrollableTabContent({
15
+ rawText: Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"),
16
+ displayLines: Array.from({ length: 50 }, (_, i) => `line ${i}`),
17
+ theme,
18
+ }, "Text");
19
+ const overlay = new TabbedOverlay({ title: "Context", subtitle: "test", tabs: [tab], theme, done: () => {} }, {
20
+ terminal,
21
+ } as TUI);
22
+ return { overlay, terminal };
23
+ }
24
+
25
+ describe("overlay height", () => {
26
+ it("keeps the footer inside the overlay at short terminal heights", () => {
27
+ const { overlay } = createOverlay(20);
28
+ const lines = overlay.render(80);
29
+ expect(lines).toHaveLength(18);
30
+ expect(lines.at(-2)).toContain("q close");
31
+ expect(lines.at(-1)).toContain("╰");
32
+ });
33
+
34
+ it("renders the complete frame when only seven rows fit", () => {
35
+ const { overlay } = createOverlay(8);
36
+ const lines = overlay.render(80);
37
+ expect(lines).toHaveLength(7);
38
+ expect(lines.at(-2)).toContain("q close");
39
+ expect(lines.at(-1)).toContain("╰");
40
+ });
41
+
42
+ it("uses the normal content height when there is enough room", () => {
43
+ const { overlay } = createOverlay(50);
44
+ expect(overlay.render(80)).toHaveLength(35);
45
+ });
46
+
47
+ it("updates page scrolling after a terminal resize", () => {
48
+ const { overlay, terminal } = createOverlay(50);
49
+ overlay.render(80);
50
+ terminal.rows = 20;
51
+ overlay.render(80);
52
+ overlay.handleInput("\x1b[6~");
53
+ const lines = overlay.render(80);
54
+ expect(lines[4]).toContain("line 9");
55
+ expect(lines.at(-2)).toContain("q close");
56
+ });
57
+ });
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
- import { Key, matchesKey } from "@earendil-works/pi-tui";
14
+ import { Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
15
15
  import { createBorderHelpers, createTitle, CONTENT_HEIGHT } from "./utils.js";
16
16
 
17
17
  /**
@@ -63,10 +63,13 @@ export interface TabbedOverlayOptions {
63
63
  done: () => void;
64
64
  }
65
65
 
66
+ export const OVERLAY_HEIGHT_PERCENT = 90;
67
+ const FRAME_HEIGHT = 7; // Top, title, tabs, separator, footer separator, footer, bottom.
68
+
66
69
  export class TabbedOverlay {
67
70
  private activeTabIndex = 0;
68
71
 
69
- constructor(private opts: TabbedOverlayOptions) {}
72
+ constructor(private opts: TabbedOverlayOptions, private tui: TUI) {}
70
73
 
71
74
  private get activeTab(): TabContent {
72
75
  return this.opts.tabs[this.activeTabIndex]!;
@@ -96,6 +99,9 @@ export class TabbedOverlay {
96
99
  render(width: number): string[] {
97
100
  const th = this.opts.theme;
98
101
  const innerW = width - 2;
102
+ // Pi clips overlay lines beyond maxHeight from the bottom, including the footer.
103
+ const maxHeight = Math.max(1, Math.floor(this.tui.terminal.rows * OVERLAY_HEIGHT_PERCENT / 100));
104
+ const contentHeight = Math.min(CONTENT_HEIGHT, Math.max(0, maxHeight - FRAME_HEIGHT));
99
105
  const lines: string[] = [];
100
106
 
101
107
  const { row, borderTop, borderSep, borderBottom } = createBorderHelpers(th, innerW);
@@ -126,8 +132,8 @@ export class TabbedOverlay {
126
132
  }
127
133
 
128
134
  // ── Content area ─────────────────────────────────────────────────────────
129
- const contentLines = this.activeTab.renderContent(innerW, CONTENT_HEIGHT);
130
- for (let i = 0; i < CONTENT_HEIGHT; i++) {
135
+ const contentLines = this.activeTab.renderContent(innerW, contentHeight);
136
+ for (let i = 0; i < contentHeight; i++) {
131
137
  if (i < contentLines.length) {
132
138
  lines.push(row(contentLines[i]!));
133
139
  } else {