mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): show managed-tool startup status in TUI
This commit is contained in:
parent
46bb9a2c3b
commit
6f707eb360
8 changed files with 153 additions and 30 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
47
packages/coding-agent/test/tools-manager.test.ts
Normal file
47
packages/coding-agent/test/tools-manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue