fix(coding-agent): show managed-tool startup status in TUI

This commit is contained in:
Vegard Stikbakke 2026-08-13 12:15:49 +02:00
parent 46bb9a2c3b
commit 6f707eb360
8 changed files with 153 additions and 30 deletions

View file

@ -17,6 +17,7 @@
### Fixed
- Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it.
- Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh.
- Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)).
- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query.

View file

@ -222,7 +222,7 @@ export function createFindToolDefinition(
}
// Default implementation uses fd.
const fdPath = await ensureTool("fd", true);
const fdPath = await ensureTool("fd");
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;

View file

@ -174,7 +174,7 @@ export function createGrepToolDefinition(
(async () => {
try {
const rgPath = await ensureTool("rg", true);
const rgPath = await ensureTool("rg");
if (!rgPath) {
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
return;

View file

@ -107,7 +107,7 @@ import { openBrowser } from "../../utils/open-browser.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
@ -436,6 +436,7 @@ export class InteractiveMode {
// Status line tracking (for mutating immediately-sequential status updates)
private lastStatusSpacer: Spacer | undefined = undefined;
private lastStatusText: Text | undefined = undefined;
private managedToolStatusStarted = false;
// Streaming message tracking
private streamingComponent: AssistantMessageComponent | undefined = undefined;
@ -851,11 +852,6 @@ export class InteractiveMode {
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
this.fdPath = fdPath;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
const modelList = this.session.scopedModels
.map((sm) => {
@ -901,11 +897,12 @@ export class InteractiveMode {
this.widgetContainerBelow,
this.footerContainer,
]);
// Accept text while startup completes, but only enable interrupt, exit, and submission feedback.
this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
this.defaultEditor.onCtrlD = () => this.handleCtrlD();
this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
@ -974,6 +971,20 @@ export class InteractiveMode {
}
this.ui.requestRender();
// Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir)
// so slow downloads do not make startup appear frozen.
// Both are needed: fd for autocomplete, rg for grep tool and bash commands.
const [fdPath] = await Promise.all([
ensureTool("fd", (status) => this.showManagedToolStatus(status)),
ensureTool("rg", (status) => this.showManagedToolStatus(status)),
]);
this.fdPath = fdPath;
// Enable the remaining input handlers only after managed-tool setup completes.
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
this.ui.requestRender();
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
@ -2873,6 +2884,11 @@ export class InteractiveMode {
}
}
private handleStartupSubmit(text: string): void {
this.editor.setText(text);
this.showStatus("Startup is still in progress");
}
private setupEditorSubmitHandler(): void {
this.defaultEditor.onSubmit = async (text: string) => {
text = text.trim();
@ -3408,6 +3424,20 @@ export class InteractiveMode {
return textBlocks.map((c) => (c as { text: string }).text).join("");
}
/** Show a managed-tool status update in the chat. */
private showManagedToolStatus(status: ToolStatus): void {
if (!this.managedToolStatusStarted) {
this.chatContainer.addChild(new Spacer(1));
this.managedToolStatusStarted = true;
}
const message = status.type === "warning" ? `Warning: ${status.message}` : status.message;
const color = status.type === "warning" ? "warning" : "dim";
this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0));
this.lastStatusSpacer = undefined;
this.lastStatusText = undefined;
this.ui.requestRender();
}
/**
* Show a status message in the chat.
*

View file

@ -1,4 +1,3 @@
import chalk from "chalk";
import { type SpawnSyncReturns, spawnSync } from "child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
import { arch, platform } from "os";
@ -323,9 +322,20 @@ const TERMUX_PACKAGES: Record<string, string> = {
rg: "ripgrep",
};
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | undefined> {
export interface ToolStatus {
type: "info" | "warning";
message: string;
}
/**
* Ensure a tool is available, downloading if necessary.
* Reports progress through `onStatus`; status messages are otherwise silent.
* Returns the tool path, or undefined if unavailable.
*/
export async function ensureTool(
tool: "fd" | "rg",
onStatus?: (status: ToolStatus) => void,
): Promise<string | undefined> {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
@ -335,9 +345,7 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr
if (!config) return undefined;
if (isOfflineModeEnabled()) {
if (!silent) {
console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));
}
onStatus?.({ type: "warning", message: `${config.name} not found. Offline mode enabled, skipping download.` });
return undefined;
}
@ -345,27 +353,22 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr
// Users must install via pkg.
if (platform() === "android") {
const pkgName = TERMUX_PACKAGES[tool] ?? tool;
if (!silent) {
console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));
}
onStatus?.({ type: "warning", message: `${config.name} not found. Install with: pkg install ${pkgName}` });
return undefined;
}
// Tool not found - download it
if (!silent) {
console.log(chalk.dim(`${config.name} not found. Downloading...`));
}
onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` });
try {
const path = await downloadTool(tool);
if (!silent) {
console.log(chalk.dim(`${config.name} installed to ${path}`));
}
onStatus?.({ type: "info", message: `${config.name} installed to ${path}` });
return path;
} catch (e) {
if (!silent) {
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
}
onStatus?.({
type: "warning",
message: `Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`,
});
return undefined;
}
}

View file

@ -23,7 +23,13 @@ type InputContext = {
pendingUserInputs: string[];
};
type StartupSubmitContext = {
editor: { setText: (text: string) => void };
showStatus: (message: string) => void;
};
type InteractiveModePrivate = {
handleStartupSubmit(this: StartupSubmitContext, text: string): void;
setupEditorSubmitHandler(this: SubmitContext): void;
getUserInput(this: InputContext): Promise<string>;
};
@ -49,6 +55,18 @@ function createSubmitContext(): SubmitContext {
}
describe("InteractiveMode startup input", () => {
it("restores a prompt submitted while managed-tool setup is running", () => {
const context: StartupSubmitContext = {
editor: { setText: vi.fn() },
showStatus: vi.fn(),
};
interactiveModePrototype.handleStartupSubmit.call(context, "early prompt");
expect(context.editor.setText).toHaveBeenCalledWith("early prompt");
expect(context.showStatus).toHaveBeenCalledWith("Startup is still in progress");
});
it("queues a normal prompt submitted before the input callback is installed", async () => {
const context = createSubmitContext();
interactiveModePrototype.setupEditorSubmitHandler.call(context);

View file

@ -118,6 +118,30 @@ describe("InteractiveMode.showStatus", () => {
});
});
describe("InteractiveMode.showManagedToolStatus", () => {
beforeAll(() => initTheme("dark"));
test("renders tool updates as one contiguous group", () => {
const fakeThis: any = {
chatContainer: new Container(),
ui: { requestRender: vi.fn() },
managedToolStatusStarted: false,
lastStatusSpacer: undefined,
lastStatusText: undefined,
};
const showManagedToolStatus = (InteractiveMode as any).prototype.showManagedToolStatus;
showManagedToolStatus.call(fakeThis, { type: "info", message: "fd downloading" });
showManagedToolStatus.call(fakeThis, { type: "info", message: "rg downloading" });
showManagedToolStatus.call(fakeThis, { type: "warning", message: "rg failed" });
expect(fakeThis.chatContainer.children).toHaveLength(4);
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toBe(
"fd downloading\n rg downloading\n Warning: rg failed",
);
});
});
describe("InteractiveMode.setToolsExpanded", () => {
test("applies expansion state to the active header and chat entries", () => {
const header = { setExpanded: vi.fn() };

View file

@ -0,0 +1,47 @@
import type * as ChildProcess from "node:child_process";
import type * as Fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureTool, type ToolStatus } from "../src/utils/tools-manager.ts";
const originalOffline = process.env.PI_OFFLINE;
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof Fs>();
return {
...actual,
existsSync: vi.fn(() => false),
};
});
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>();
return {
...actual,
spawnSync: vi.fn(() => ({ error: new Error("not found") })),
};
});
afterEach(() => {
if (originalOffline === undefined) delete process.env.PI_OFFLINE;
else process.env.PI_OFFLINE = originalOffline;
});
describe("ensureTool", () => {
it("reports status through a callback without writing to the console", async () => {
process.env.PI_OFFLINE = "1";
const statuses: ToolStatus[] = [];
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await ensureTool("fd", (status) => statuses.push(status));
expect(result).toBeUndefined();
expect(statuses).toEqual([
{
type: "warning",
message: "fd not found. Offline mode enabled, skipping download.",
},
]);
expect(consoleLog).not.toHaveBeenCalled();
consoleLog.mockRestore();
});
});