Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/engineering/overlay.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Overlay 底部输入框接收普通 Ask Advisor 消息,也识别以下控制

其他 `/` 前缀输入透传给 Advisor Session。Overlay 打开时,方向键、Page Up/Page Down、鼠标滚轮和触控板滚动只操作 Overlay transcript。

终端 mouse reporting 的所有权随 Pi TUI 模式划分:regular 模式下 Pi 不管理鼠标,Overlay 在聚焦时自行开启 SGR mouse reporting(`1000`/`1006`)并在失焦或销毁时关闭;fullscreen 模式下 Pi 自己持有鼠标模式(滚动、选择、滚动条),Overlay 不触碰终端 mouse 序列,直接消费 Pi 透传给聚焦 Overlay 的滚轮输入。

Overlay transcript 使用 Pi TUI 的 `Container`、`Box`、`Text` 和 `Spacer` 组合渲染。用户消息、Context、Pull、普通工具和 Advice Block 的全宽背景由 `Box` 负责,颜色直接使用 Pi theme token;这些 Block 与 Pi 官方消息和工具组件一样使用 `Box(1, 1, ...)` 的水平与垂直内边距,条目之间保留一行 `Spacer(1)`。Advice 使用现有错误背景作为视觉别名,标题和正文使用 `text`。Context 与 Pull 默认各展示前 5 个视觉行,长单条内容换行后仍受同一高度上限约束;Overlay 通过注入的 KeybindingsManager 监听 `app.tools.expand`,统一切换所有 Context 与 Pull Block 的展开状态,并在关闭、重新打开期间保留该状态。切换前记录当前 viewport 中的 transcript 行锨点,重建内容后定位同一条目,避免完整 payload 变长时强制跳到 transcript 底部。用户解绑该 action 时,折叠提示只报告剩余视觉行数,不展示不可用的快捷键。

Overlay Component 直接服从 Pi 传入的实际宽度与终端可用高度。Transcript viewport 可以缩小到 0 行,优先保留 Header、输入分隔线、输入框和底部边框,避免 Pi 对过大的 component 从底部裁剪后隐藏输入区域。
Expand Down
17 changes: 17 additions & 0 deletions e2e/features/tui-overlay.feature
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ Feature: Advisor Overlay
And normal terminal mouse interaction should be available
And the main input should accept "Draft"

Scenario: Closing Advisor Overlay keeps fullscreen terminal mouse interaction active
Given Advisor is configured in a compact fullscreen interactive terminal
When the user opens Advisor Overlay from the main input
Then Advisor Overlay should be visible
When the user leaves Advisor Overlay
Then Advisor Overlay should be hidden
And fullscreen terminal mouse interaction should remain active

Scenario: Trackpad drift does not bounce Advisor Overlay off the transcript bottom
Comment thread
kkkiio marked this conversation as resolved.
Given Advisor is configured in a compact interactive terminal
When the user gives Primary Agent work to review
Then Primary Agent should finish the work for Advisor
When the user asks Advisor from the main input "Review the primary transcript."
Then Advisor Overlay should show a completed Second Opinion
When the user swipes down with horizontal drift on the Advisor Overlay trackpad
Then Advisor Overlay should stay at the transcript bottom

Scenario: Leaving and returning to Advisor preserves its transcript
Given Advisor is configured in the interactive terminal
When the user gives Primary Agent work to review
Expand Down
20 changes: 12 additions & 8 deletions e2e/steps/advisor.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,19 @@ When(
When("the Primary Agent response {string} becomes visible", async function (this: AdvisorE2EWorld, text: string) {
const started = Date.now();
while (Date.now() - started < 20_000) {
const update = this.rpcPi.events
// RPC message_update events carry streaming deltas (assistantMessageEvent)
// without the accumulated message, so rebuild the streamed text from deltas.
const streamed = this.rpcPi.events
.slice(this.lastEventIndex)
.find(
(event) =>
event.type === "message_update" &&
event.message?.role === "assistant" &&
JSON.stringify(event.message).includes(text),
);
if (update) {
.flatMap((event) => {
if (event.type !== "message_update") {
return [];
}
const delta = (event as { assistantMessageEvent?: { type?: string; delta?: unknown } }).assistantMessageEvent;
return delta?.type === "text_delta" && typeof delta.delta === "string" ? [delta.delta] : [];
})
.join("");
if (streamed.includes(text)) {
return;
}
await this.rpcPi.sleep(100);
Expand Down
31 changes: 31 additions & 0 deletions e2e/steps/tui.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Given("Advisor is configured in a compact interactive terminal", async function
await this.startTuiPi({ advisorModelConfigured: true, width: 100, height: 14 });
});

Given("Advisor is configured in a compact fullscreen interactive terminal", async function (this: AdvisorE2EWorld) {
await this.startTuiPi({ advisorModelConfigured: true, width: 100, height: 14, tuiMode: "fullscreen" });
});

Given("Advisor uses Ctrl+X to expand chats in the interactive terminal", async function (this: AdvisorE2EWorld) {
await this.startTuiPi({
advisorModelConfigured: true,
Expand Down Expand Up @@ -85,6 +89,29 @@ When("the user scrolls Advisor Overlay upward with the mouse wheel", async funct
this.tuiPi.sendRawInput("\x1b[<64;50;8M");
});

When(
"the user swipes down with horizontal drift on the Advisor Overlay trackpad",
async function (this: AdvisorE2EWorld) {
// Trackpads emit horizontal wheel events (buttons 66/67) during diagonal swipes;
// the sequence ends on a horizontal event so a vertical misreading leaves the
// viewport visibly stuck above the bottom.
for (let i = 0; i < 3; i++) {
this.tuiPi.sendRawInput("\x1b[<65;50;8M");
this.tuiPi.sendRawInput("\x1b[<66;50;8M");
}
await new Promise((resolve) => setTimeout(resolve, 300));
},
);

Then("Advisor Overlay should stay at the transcript bottom", async function (this: AdvisorE2EWorld) {
await this.tuiPi.waitForScreen(
(candidate) => /· ↑\d+ ↓0/.test(candidate),
5_000,
"Advisor Overlay pinned to the transcript bottom",
);
expect(this.tuiPi.captureAdvisorOverlayPlainText()).toMatch(/· ↑\d+ ↓0/);
});

When("the user opens the Advisor model picker in the terminal", async function (this: AdvisorE2EWorld) {
await this.tuiPi.submit("/advisor:model");
this.lastTuiScreen = await this.tuiPi.waitForScreen(
Expand Down Expand Up @@ -329,6 +356,10 @@ Then("normal terminal mouse interaction should be available", async function (th
await this.tuiPi.waitForMouseReporting(false, 2_000, "normal terminal mouse interaction");
});

Then("fullscreen terminal mouse interaction should remain active", async function (this: AdvisorE2EWorld) {
await this.tuiPi.waitForAnyMouseReporting(true, 2_000, "fullscreen terminal mouse interaction");
});

Then("Primary Agent should finish the work for Advisor", async function (this: AdvisorE2EWorld) {
const screen = await this.tuiPi.waitForScreen(
(candidate) => candidate.includes("E2E_PRIMARY_RESPONSE: primary agent completed a deterministic faux turn."),
Expand Down
22 changes: 14 additions & 8 deletions e2e/support/tui-pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface TuiPiOptions {
advisorModelConfigured?: boolean;
color?: boolean;
expandToolsKey?: string;
tuiMode?: "regular" | "fullscreen";
script?:
"default" | "overlay-pull-collapse" | "readme" | "visual-natural" | "visual-overlay-pull-collapse" | "watch-wait";
width?: number;
Expand Down Expand Up @@ -151,17 +152,21 @@ export class TuiPi {
}

async waitForMouseReporting(expected: boolean, timeoutMs: number, label: string): Promise<void> {
await this.waitForMouseFlags(expected ? "1:1" : "0:0", "#{mouse_standard_flag}:#{mouse_sgr_flag}", timeoutMs, label);
}

async waitForAnyMouseReporting(expected: boolean, timeoutMs: number, label: string): Promise<void> {
// Fullscreen pi enables button-motion tracking (1002), which tmux reports
// through mouse_any_flag rather than mouse_standard_flag.
await this.waitForMouseFlags(expected ? "1:1" : "0:0", "#{mouse_any_flag}:#{mouse_sgr_flag}", timeoutMs, label);
}

private async waitForMouseFlags(expected: string, format: string, timeoutMs: number, label: string): Promise<void> {
const started = Date.now();
let lastOutput = "";
while (Date.now() - started < timeoutMs) {
lastOutput = this.tmux([
"list-panes",
"-t",
this.sessionName,
"-F",
"#{mouse_standard_flag}:#{mouse_sgr_flag}",
]).trim();
if ((lastOutput === "1:1") === expected) {
lastOutput = this.tmux(["list-panes", "-t", this.sessionName, "-F", format]).trim();
if (lastOutput === expected) {
return;
}
await this.sleep(25);
Expand Down Expand Up @@ -255,6 +260,7 @@ export class TuiPi {
"CI=1",
"PI_OFFLINE=1",
shellQuote(resolvePiBin()),
...(options.tuiMode ? ["--tui-mode", options.tuiMode] : []),
"--offline",
"--no-session",
"--no-extensions",
Expand Down
70 changes: 64 additions & 6 deletions extensions/advisor/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type ExtensionContext,
type KeybindingsManager,
} from "@earendil-works/pi-coding-agent";
import { appendFileSync } from "node:fs";
import type { Component, Focusable, OverlayHandle, TUI } from "@earendil-works/pi-tui";
import { Container, Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AdviceDeliveryResult, AdvisorContextUsage, AskContextPayload, WatchRunState } from "./types";
Expand Down Expand Up @@ -158,6 +159,7 @@ export class AdvisorOverlayComponent extends Container implements Focusable {
private readonly onDismissCallback: () => void;
private transcriptComponent: Component;
private transcriptScrollOffset = 0;
private lastMaxScroll = 0;
private transcriptViewportHeight = 8;
private transcriptRenderWidth = 1;
private followTranscript = true;
Expand All @@ -172,7 +174,7 @@ export class AdvisorOverlayComponent extends Container implements Focusable {
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
if (this.mouseReportingEnabled === value) {
if (!this.shouldManageMouseReporting() || this.mouseReportingEnabled === value) {
return;
}
this.tui.terminal?.write?.(value ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
Expand Down Expand Up @@ -227,13 +229,20 @@ export class AdvisorOverlayComponent extends Container implements Focusable {
}

dispose(): void {
if (this.mouseReportingEnabled) {
if (this.mouseReportingEnabled && this.shouldManageMouseReporting()) {
this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
this.mouseReportingEnabled = false;
}
}

// The fullscreen TUI owns terminal mouse modes for its own scrolling, selection,
// and scrollbar. Only the regular TUI needs the overlay to enable reporting itself.
private shouldManageMouseReporting(): boolean {
return this.tui.mode !== "fullscreen";
}

handleInput(data: string): void {
this.recordInputDebug(data);
if (matchesKey(data, ADVISOR_OVERLAY_SHORTCUT)) {
this.onDismissCallback();
return;
Expand Down Expand Up @@ -274,7 +283,11 @@ export class AdvisorOverlayComponent extends Container implements Focusable {
}
const mouseScrollDelta = this.getMouseScrollDelta(data);
if (mouseScrollDelta !== null) {
this.scrollTranscript(mouseScrollDelta);
if (mouseScrollDelta !== 0) {
this.scrollTranscript(mouseScrollDelta);
}
// Consume every SGR mouse event; never leak clicks or horizontal
// wheel sequences into the input.
return;
}
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.up)) {
Expand All @@ -300,6 +313,7 @@ export class AdvisorOverlayComponent extends Container implements Focusable {
const transcriptLines = this.transcriptComponent.render(innerWidth);

const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight);
this.lastMaxScroll = maxScroll;
if (this.followTranscript) {
this.transcriptScrollOffset = maxScroll;
} else {
Expand Down Expand Up @@ -372,26 +386,70 @@ export class AdvisorOverlayComponent extends Container implements Focusable {
return Math.max(4, terminalRows);
}

/**
* Returns the vertical scroll delta for an SGR mouse event, 0 for mouse events
* that must be consumed without scrolling (horizontal wheel, clicks), or null
* when the input is not an SGR mouse event. Buttons 64/65 are wheel up/down;
* 66/67 are horizontal wheel, which trackpads emit during diagonal swipes.
*/
private getMouseScrollDelta(data: string): number | null {
const match = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
if (!match) {
return null;
}
const button = Number(match[1]);
if ((button & 64) !== 64) {
return null;
return 0;
}
const direction = button & 3;
if (direction === 0) {
return -3;
}
return (button & 1) === 0 ? -3 : 3;
if (direction === 1) {
return 3;
}
return 0;
}

private scrollTranscript(delta: number): void {
if (delta > 0 && this.followTranscript) {
// Already following the bottom: scrolling down cannot change the view.
return;
}
const previousOffset = this.transcriptScrollOffset;
const previousFollow = this.followTranscript;
if (delta < 0) {
this.followTranscript = false;
}
this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset + delta);
// Clamp against the last rendered maxScroll so rapid scroll bursts cannot
// overshoot the bottom between frames.
this.transcriptScrollOffset = Math.max(0, Math.min(this.lastMaxScroll, this.transcriptScrollOffset + delta));
if (this.transcriptScrollOffset >= this.lastMaxScroll) {
this.followTranscript = true;
}
if (this.transcriptScrollOffset === previousOffset && this.followTranscript === previousFollow) {
// Nothing changed; avoid a redundant repaint.
return;
}
this.tui.requestRender();
}

// PI_ADVISOR_DEBUG_LOG=/path/to/log captures the raw overlay input stream and
// scroll transitions for diagnosing terminal-specific input issues.
private recordInputDebug(data: string): void {
const logPath = process.env.PI_ADVISOR_DEBUG_LOG;
if (!logPath) {
return;
}
const escaped = JSON.stringify(data);
const line = `${Date.now()} input=${escaped} offset=${this.transcriptScrollOffset} follow=${this.followTranscript}\n`;
try {
appendFileSync(logPath, line);
} catch {
// Debug logging must never break the overlay.
}
}

private fitRenderedLine(line: string, width: number): string {
return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
}
Expand Down
32 changes: 27 additions & 5 deletions extensions/advisor/session.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
DefaultResourceLoader,
ModelSelectorComponent,
type ModelRuntime,
SessionManager,
SettingsManager,
createAgentSession,
type AgentSession,
type AgentSessionEvent,
Expand Down Expand Up @@ -59,6 +59,17 @@ interface LatestSecondOpinion {
answer: string;
}

/**
* ExtensionContext only exposes the synchronous ModelRegistry compatibility facade,
* while the SDK's createAgentSession and ModelSelectorComponent take the shared
* ModelRuntime behind it. The facade stores the runtime as a plain
* (TypeScript-private) property. Sharing the Primary's runtime keeps
* extension-registered providers and runtime credentials available to Advisor.
*/
function resolveModelRuntime(ctx: ExtensionContext): ModelRuntime | undefined {
return (ctx.modelRegistry as unknown as { runtime?: ModelRuntime }).runtime;
}

export class AdvisorRuntime implements AdvisorRuntimePort {
private readonly pi: ExtensionAPI;
private readonly settingsStore: AdvisorSettingsStore;
Expand Down Expand Up @@ -451,11 +462,14 @@ Use pull_transcript with timeout_ms to follow Primary Agent progress. Send Hint
if (ctx.mode === "tui") {
const currentRef = settings.model ? parseAdvisorModelRef(settings.model) : undefined;
const currentModel = currentRef ? ctx.modelRegistry.find(currentRef.provider, currentRef.id) : undefined;
const modelRuntime = resolveModelRuntime(ctx);
if (!modelRuntime) {
ctx.ui.notify("Advisor model picker requires Pi's model runtime.", "error");
return;
}
const selected = await ctx.ui.custom<Model<any> | undefined>(
(tui, _theme, _keybindings, done) =>
new ModelSelectorComponent(tui, currentModel, SettingsManager.inMemory(), ctx.modelRegistry, [], done, () =>
done(undefined),
),
new ModelSelectorComponent(tui, currentModel, modelRuntime, [], done, () => done(undefined)),
);
if (!selected) {
return;
Expand Down Expand Up @@ -622,6 +636,14 @@ Use pull_transcript with timeout_ms to follow Primary Agent progress. Send Hint
this.overlay.refresh();
return undefined;
}
const modelRuntime = resolveModelRuntime(ctx);
if (!modelRuntime) {
const message = "Advisor session requires Pi's model runtime.";
ctx.ui.notify(message, "warning");
this.overlay.state.recordError(new Error(message));
this.overlay.refresh();
return undefined;
}
const primaryTools = this.pi
.getActiveTools()
.filter((toolName) => !ADVISOR_DISABLED_PRIMARY_TOOL_NAMES.has(toolName));
Expand All @@ -641,7 +663,7 @@ Use pull_transcript with timeout_ms to follow Primary Agent progress. Send Hint
cwd: ctx.cwd,
sessionManager: SessionManager.inMemory(ctx.cwd),
model: resolved.model,
modelRegistry: ctx.modelRegistry,
modelRuntime,
thinkingLevel: resolved.thinkingLevel,
tools,
customTools: createAdvisorTools(this),
Expand Down
Loading
Loading