diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index b7ac9333..fbbea170 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -4,6 +4,7 @@ # issue future issues stay open # pr future issues and PRs stay open +julien-c pr barapa pr alasano pr aadishv pr diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 432e5254..193c7c38 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -61,22 +61,21 @@ export interface ReadToolOptions { operations?: ReadOperations; } -function formatReadCall( - args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined, - theme: Theme, -): string { +type ReadRenderArgs = { path?: string; file_path?: string; offset?: number; limit?: number }; + +function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string { + if (args?.offset === undefined && args?.limit === undefined) return ""; + const startLine = args.offset ?? 1; + const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; + return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); +} + +function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme): string { const rawPath = str(args?.file_path ?? args?.path); const path = rawPath !== null ? shortenPath(rawPath) : null; - const offset = args?.offset; - const limit = args?.limit; const invalidArg = invalidArgText(theme); - let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); - if (offset !== undefined || limit !== undefined) { - const startLine = offset ?? 1; - const endLine = limit !== undefined ? startLine + limit - 1 : ""; - pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); - } - return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`; + const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`; } function trimTrailingEmptyLines(lines: string[]): string[] { @@ -118,7 +117,7 @@ function getPiDocsClassification(absolutePath: string): CompactReadClassificatio } function getCompactReadClassification( - args: { path?: string; file_path?: string } | undefined, + args: ReadRenderArgs | undefined, cwd: string, ): CompactReadClassification | undefined { const rawPath = str(args?.file_path ?? args?.path); @@ -140,12 +139,18 @@ function getCompactReadClassification( return undefined; } -function formatCompactReadCall(classification: CompactReadClassification, theme: Theme): string { +function formatCompactReadCall( + classification: CompactReadClassification, + args: ReadRenderArgs | undefined, + theme: Theme, +): string { + const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); if (classification.kind === "skill") { return ( theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + theme.fg("customMessageText", classification.label) + - theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`) + formatReadLineRange(args, theme) + + expandHint ); } @@ -153,12 +158,13 @@ function formatCompactReadCall(classification: CompactReadClassification, theme: theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) + " " + theme.fg("accent", classification.label) + - theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`) + formatReadLineRange(args, theme) + + expandHint ); } function formatReadResult( - args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined, + args: ReadRenderArgs | undefined, result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails }, options: ToolRenderResultOptions, theme: Theme, @@ -336,7 +342,9 @@ export function createReadToolDefinition( renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined; - text.setText(classification ? formatCompactReadCall(classification, theme) : formatReadCall(args, theme)); + text.setText( + classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme), + ); return text; }, renderResult(result, options, theme, context) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3b2adb5d..2fab6f56 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -163,6 +163,16 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; +const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]); + +function isDeadTerminalError(error: unknown): boolean { + if (!error || typeof error !== "object" || !("code" in error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; @@ -3223,6 +3233,15 @@ export class InteractiveMode { process.exit(0); } + private emergencyTerminalExit(): never { + this.isShuttingDown = true; + this.unregisterSignalHandlers(); + killTrackedDetachedChildren(); + // The terminal is gone. Do not run normal shutdown because TUI and + // extension cleanup can write restore sequences and re-trigger EIO. + process.exit(129); + } + /** * Check if shutdown was requested and perform shutdown if so. */ @@ -3241,12 +3260,26 @@ export class InteractiveMode { for (const signal of signals) { const handler = () => { + if (signal === "SIGHUP") { + this.emergencyTerminalExit(); + } killTrackedDetachedChildren(); void this.shutdown(); }; - process.on(signal, handler); + process.prependListener(signal, handler); this.signalCleanupHandlers.push(() => process.off(signal, handler)); } + + const terminalErrorHandler = (error: Error) => { + if (isDeadTerminalError(error)) { + this.emergencyTerminalExit(); + } + throw error; + }; + process.stdout.on("error", terminalErrorHandler); + process.stderr.on("error", terminalErrorHandler); + this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); + this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); } private unregisterSignalHandlers(): void { diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 6c37b65e..3c200bb1 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -384,4 +384,25 @@ describe("ToolExecutionComponent parity", () => { expect(expanded).toContain(scenario.hidden); }); } + + for (const scenario of [ + { title: "SKILL.md", path: join(process.cwd(), "attio", "SKILL.md"), compact: "[skill] attio:120-329" }, + { title: "Pi documentation", path: getReadmePath(), compact: "read docs README.md:120-329" }, + ] as const) { + test(`shows the read line range in compact ${scenario.title} reads before the expand hint`, () => { + const component = new ToolExecutionComponent( + "read", + `tool-compact-range-${scenario.title}`, + { path: scenario.path, offset: 120, limit: 210 }, + {}, + createReadToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).toContain(scenario.compact); + expect(collapsed.indexOf(":120-329")).toBeLessThan(collapsed.indexOf("to expand")); + }); + } }); diff --git a/pi-test.sh b/pi-test.sh index c4479af9..ece44929 100755 --- a/pi-test.sh +++ b/pi-test.sh @@ -33,6 +33,7 @@ if [[ "$NO_ENV" == "true" ]]; then unset COPILOT_GITHUB_TOKEN unset GH_TOKEN unset GITHUB_TOKEN + unset HF_TOKEN unset GOOGLE_APPLICATION_CREDENTIALS unset GOOGLE_CLOUD_PROJECT unset GCLOUD_PROJECT