diff --git a/package.json b/package.json index 3d3ff41..de5c588 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "scripts": { "dev": "bun src/cli.ts", + "test": "bun test", "build:single": "bun run script/build.ts --single", "build:all": "bun run script/build.ts", "release:publish": "bun run script/publish.ts", diff --git a/src/hooks.ts b/src/hooks.ts index 3692b01..c051a88 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { stripVTControlCharacters } from "node:util"; export type HookResult = { success: boolean; @@ -10,6 +11,35 @@ export type HookCallbacks = { onComplete: (result: HookResult) => void; }; +/** + * Normalize raw hook output for display in the TUI: + * strips ANSI/VT escape sequences and applies carriage-return + * (progress bar) overwrite semantics. + */ +export const normalizeHookOutput = (raw: string): string => { + const stripped = stripVTControlCharacters(raw); + let out = ""; + let line = ""; + for (let i = 0; i < stripped.length; i++) { + const ch = stripped[i]; + if (ch === "\r") { + if (stripped[i + 1] === "\n") { + out += line + "\n"; + line = ""; + i++; + } else { + line = ""; + } + } else if (ch === "\n") { + out += line + "\n"; + line = ""; + } else { + line += ch; + } + } + return out + line; +}; + /** * Run a post-create hook command with streaming output * Returns a function to abort the hook if needed @@ -19,44 +49,76 @@ export const runPostCreateHook = ( command: string, callbacks: HookCallbacks ): (() => void) => { - const shell = process.platform === "win32" ? "cmd" : "/bin/sh"; - const shellFlag = process.platform === "win32" ? "/c" : "-c"; + const isWin = process.platform === "win32"; + const shell = isWin ? "cmd" : "/bin/sh"; + const shellFlag = isWin ? "/c" : "-c"; + // detached starts a new process group so abort can kill the whole tree const child = spawn(shell, [shellFlag, command], { cwd: worktreePath, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, + detached: !isWin, }); - // Stream stdout - child.stdout?.on("data", (data: Buffer) => { - callbacks.onOutput(data.toString()); - }); + // Decode streams as UTF-8 so multibyte characters are not split across chunks + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + let settled = false; + let aborted = false; - // Stream stderr - child.stderr?.on("data", (data: Buffer) => { - callbacks.onOutput(data.toString()); + const settle = (result: HookResult): void => { + if (settled) return; + settled = true; + if (aborted) return; // caller already handled the abort + callbacks.onComplete(result); + }; + + child.stdout?.on("data", (data: string) => { + if (aborted) return; + callbacks.onOutput(data); }); - // Handle completion - child.on("close", (code: number | null) => { - callbacks.onComplete({ - success: code === 0, - exitCode: code, - }); + child.stderr?.on("data", (data: string) => { + if (aborted) return; + callbacks.onOutput(data); }); - // Handle errors child.on("error", (err: Error) => { + if (aborted) return; callbacks.onOutput(`Error: ${err.message}\n`); - callbacks.onComplete({ - success: false, - exitCode: null, - }); + settle({ success: false, exitCode: null }); + }); + + child.on("close", (code: number | null) => { + settle({ success: code === 0, exitCode: code }); }); - // Return abort function + // Abort: terminate the whole process group return () => { - child.kill("SIGTERM"); + if (settled) return; + aborted = true; + const pid = child.pid; + if (pid === undefined) return; + + if (isWin) { + spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { + stdio: "ignore", + }); + } else { + try { + process.kill(-pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + // already gone + } + }, 2000); + } }; }; diff --git a/src/ui.ts b/src/ui.ts index 1dedbeb..5111700 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -28,7 +28,7 @@ import { import { isCommandAvailable, launchCommand, openInFileManager } from "./opencode.js"; import { WorktreeInfo } from "./types.js"; import { loadRepoConfig, saveRepoConfig, type Config } from "./config.js"; -import { runPostCreateHook, type HookResult } from "./hooks.js"; +import { normalizeHookOutput, runPostCreateHook, type HookResult } from "./hooks.js"; type StatusLevel = "info" | "warning" | "error" | "success"; @@ -98,6 +98,7 @@ class WorktreeSelector { private hookOutputText: TextRenderable | null = null; private hookOutput: string[] = []; private hookAbortFn: (() => void) | null = null; + private hookCompleteTimer: ReturnType | null = null; private pendingWorktreePath: string | null = null; private hookFailed = false; private hookFailureSelect: SelectRenderable | null = null; @@ -264,11 +265,12 @@ class WorktreeSelector { if (key.ctrl && key.name === "c") { // If running hook, abort it first if (this.isRunningHook && this.hookAbortFn) { + const worktreePath = this.pendingWorktreePath; this.hookAbortFn(); this.hookAbortFn = null; this.setStatus("Hook aborted by user.", "warning"); this.hideHookOutput(); - this.loadWorktrees(this.pendingWorktreePath || undefined); + this.loadWorktrees(worktreePath || undefined); this.selectElement.visible = true; this.selectElement.focus(); this.instructions.content = @@ -501,7 +503,7 @@ class WorktreeSelector { }); this.inputContainer.add(this.branchInput); - this.branchInput.on(InputRenderableEvents.CHANGE, (value: string) => { + this.branchInput.on(InputRenderableEvents.ENTER, (value: string) => { this.handleCreateWorktree(value); }); @@ -621,6 +623,8 @@ class WorktreeSelector { }, onComplete: (result: HookResult) => { this.hookAbortFn = null; + // Ignore completion after abort already reset the UI + if (!this.isRunningHook) return; if (result.success) { this.onHookSuccess(); } else { @@ -633,26 +637,38 @@ class WorktreeSelector { private updateHookOutput(): void { if (!this.hookOutputText) return; - // Join all output and take the last N lines that fit in the container - const fullOutput = this.hookOutput.join(""); + // Keep a bounded tail of raw output so large/noisy hooks don't accumulate. + const MAX_RAW_BYTES = 65536; + let total = 0; + for (const chunk of this.hookOutput) { + total += chunk.length; + } + while (this.hookOutput.length > 1 && total - this.hookOutput[0].length > MAX_RAW_BYTES) { + total -= this.hookOutput[0].length; + this.hookOutput.shift(); + } + + const fullOutput = normalizeHookOutput(this.hookOutput.join("")); const lines = fullOutput.split("\n"); const maxLines = 11; // Container height minus borders and padding const visibleLines = lines.slice(-maxLines); - + this.hookOutputText.content = visibleLines.join("\n"); this.renderer.requestRender(); } private onHookSuccess(): void { + const worktreePath = this.pendingWorktreePath; this.setStatus("Hook completed successfully!", "success"); + this.instructions.content = "Hook completed. Launching..."; this.renderer.requestRender(); // Brief delay to show success, then launch command - setTimeout(() => { + this.hookCompleteTimer = setTimeout(() => { this.hideHookOutput(); - if (this.pendingWorktreePath) { + if (worktreePath) { this.cleanup(false); - launchCommand(this.pendingWorktreePath, this.repoConfig.launchCommand); + launchCommand(worktreePath, this.repoConfig.launchCommand); } }, 1000); } @@ -664,6 +680,7 @@ class WorktreeSelector { // Add failure options to the container if (this.hookOutputContainer) { + const launchName = this.repoConfig.launchCommand || "opencode"; this.hookFailureSelect = new SelectRenderable(this.renderer, { id: "hook-failure-select", position: "absolute", @@ -673,8 +690,8 @@ class WorktreeSelector { height: 2, options: [ { - name: "Open in opencode anyway", - description: "Launch opencode despite hook failure", + name: `Open in ${launchName} anyway`, + description: `Launch ${launchName} despite hook failure`, value: "open", }, { @@ -710,14 +727,15 @@ class WorktreeSelector { } private handleHookFailureChoice(choice: string): void { - if (choice === "open" && this.pendingWorktreePath) { + const worktreePath = this.pendingWorktreePath; + if (choice === "open" && worktreePath) { this.hideHookOutput(); this.cleanup(false); - launchCommand(this.pendingWorktreePath, this.repoConfig.launchCommand); + launchCommand(worktreePath, this.repoConfig.launchCommand); } else { // Cancel - return to list this.hideHookOutput(); - this.loadWorktrees(this.pendingWorktreePath || undefined); + this.loadWorktrees(worktreePath || undefined); this.selectElement.visible = true; this.selectElement.focus(); this.instructions.content = @@ -725,12 +743,18 @@ class WorktreeSelector { } } - private hideHookOutput(): void { + private hideHookOutput(): string | null { + const worktreePath = this.pendingWorktreePath; this.isRunningHook = false; this.hookFailed = false; this.hookOutput = []; this.pendingWorktreePath = null; + if (this.hookCompleteTimer) { + clearTimeout(this.hookCompleteTimer); + this.hookCompleteTimer = null; + } + if (this.hookFailureSelect) { this.hookFailureSelect.blur(); this.hookFailureSelect = null; @@ -741,6 +765,8 @@ class WorktreeSelector { this.hookOutputContainer = null; this.hookOutputText = null; } + + return worktreePath; } // ========== Config Editor Methods ========== @@ -1041,7 +1067,7 @@ class WorktreeSelector { }); this.branchCreateContainer.add(helpText); - this.branchNameInput.on(InputRenderableEvents.CHANGE, (value: string) => { + this.branchNameInput.on(InputRenderableEvents.ENTER, (value: string) => { this.handleBranchCreate(value); }); diff --git a/test/hooks.test.ts b/test/hooks.test.ts new file mode 100644 index 0000000..0a3f2ad --- /dev/null +++ b/test/hooks.test.ts @@ -0,0 +1,121 @@ +import { describe, test, expect, afterAll } from "bun:test"; +import { normalizeHookOutput, runPostCreateHook } from "../src/hooks.js"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("normalizeHookOutput", () => { + test("strips ANSI escape sequences", () => { + const input = "\u001b[38;2;160;100;90mInstalling\u001b[0m done"; + expect(normalizeHookOutput(input)).toBe("Installing done"); + }); + + test("strips OSC hyperlinks", () => { + const input = "\u001b]8;;https://example.com\u0007link\u001b]8;;\u0007"; + expect(normalizeHookOutput(input)).toBe("link"); + }); + + test("handles carriage-return progress overwrite", () => { + const input = "Scanning files...\rScanning files... 42%\rScanning files... done\n"; + expect(normalizeHookOutput(input)).toBe("Scanning files... done\n"); + }); + + test("handles CRLF as line breaks", () => { + const input = "one\r\ntwo\r\nthree"; + expect(normalizeHookOutput(input)).toBe("one\ntwo\nthree"); + }); + + test("preserves unicode", () => { + const input = "caf\u00e9 \u2014 \u2603 snowman"; + expect(normalizeHookOutput(input)).toBe(input); + }); +}); + +describe("runPostCreateHook", () => { + const workdir = mkdtempSync(join(tmpdir(), "opencode-worktree-hook-")); + + afterAll(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + test("streams output and reports success", async () => { + const output: string[] = []; + const result = await new Promise<{ success: boolean; exitCode: number | null }>((resolve) => { + const abort = runPostCreateHook(workdir, "printf 'hello'", { + onOutput: (data) => output.push(data), + onComplete: resolve, + }); + abort; // no abort expected + }); + expect(output.join("")).toBe("hello"); + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + }); + + test("reports failure on non-zero exit", async () => { + const result = await new Promise<{ success: boolean; exitCode: number | null }>((resolve) => { + runPostCreateHook(workdir, "exit 3", { + onOutput: () => {}, + onComplete: resolve, + }); + }); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + }); + + test("decodes UTF-8 output correctly", async () => { + const output: string[] = []; + await new Promise((resolve) => { + runPostCreateHook(workdir, "printf '\\303\\251\\303\\250'", { + onOutput: (data) => { + output.push(data); + }, + onComplete: () => resolve(), + }); + }); + expect(output.join("")).toBe("\u00e9\u00e8"); + }); + + test("abort suppresses completion", async () => { + const completed: string[] = []; + const abort = runPostCreateHook(workdir, "sleep 5", { + onOutput: () => {}, + onComplete: () => completed.push("done"), + }); + abort(); + await new Promise((r) => setTimeout(r, 100)); + expect(completed).toEqual([]); + }); + + test("nonexistent cwd reports failure exactly once", async () => { + const missing = join(workdir, "does-not-exist"); + expect(existsSync(missing)).toBe(false); + const completions: string[] = []; + await new Promise((resolve) => { + runPostCreateHook(missing, "echo hi", { + onOutput: () => {}, + onComplete: (result) => { + completions.push(result.success ? "ok" : "fail"); + resolve(); + }, + }); + }); + await new Promise((r) => setTimeout(r, 50)); + expect(completions).toEqual(["fail"]); + }); + + test("streamed ANSI + carriage-return output normalizes to clean tail", async () => { + const chunks: string[] = []; + await new Promise((resolve) => { + runPostCreateHook( + workdir, + "printf '\\033[1;36mindexing\\033[0m 0%%\\r\\033[1;36mindexing\\033[0m 50%%\\r\\033[1;36mindexing\\033[0m done\\n'", + { + onOutput: (data) => chunks.push(data), + onComplete: () => resolve(), + }, + ); + }); + expect(normalizeHookOutput(chunks.join(""))).toBe("indexing done\n"); + }); +});