From 6f707eb36064e82af9c1320a7634f4dfad21049b Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 13 Aug 2026 12:15:49 +0200 Subject: [PATCH] fix(coding-agent): show managed-tool startup status in TUI --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/tools/find.ts | 2 +- packages/coding-agent/src/core/tools/grep.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 48 +++++++++++++++---- .../coding-agent/src/utils/tools-manager.ts | 41 ++++++++-------- .../interactive-mode-startup-input.test.ts | 18 +++++++ .../test/interactive-mode-status.test.ts | 24 ++++++++++ .../coding-agent/test/tools-manager.test.ts | 47 ++++++++++++++++++ 8 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 packages/coding-agent/test/tools-manager.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a008fe693..00419380e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index a14f327e8..f3228ef77 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -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; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 2274e7064..a65cab9da 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5b5b3d86a..4e64341b2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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. * diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index a177b4f64..23a71b5a1 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -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 = { 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 { +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 { 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; } } diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts index b784f3769..28a0f48b8 100644 --- a/packages/coding-agent/test/interactive-mode-startup-input.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -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; }; @@ -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); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d422d8f34..a4aaa48c0 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -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() }; diff --git a/packages/coding-agent/test/tools-manager.test.ts b/packages/coding-agent/test/tools-manager.test.ts new file mode 100644 index 000000000..fb53baad4 --- /dev/null +++ b/packages/coding-agent/test/tools-manager.test.ts @@ -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(); + return { + ...actual, + existsSync: vi.fn(() => false), + }; +}); + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + 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(); + }); +});