Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 84 additions & 22 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import { stripVTControlCharacters } from "node:util";

export type HookResult = {
success: boolean;
Expand All @@ -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
Expand All @@ -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);
}
};
};
58 changes: 42 additions & 16 deletions src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -98,6 +98,7 @@ class WorktreeSelector {
private hookOutputText: TextRenderable | null = null;
private hookOutput: string[] = [];
private hookAbortFn: (() => void) | null = null;
private hookCompleteTimer: ReturnType<typeof setTimeout> | null = null;
private pendingWorktreePath: string | null = null;
private hookFailed = false;
private hookFailureSelect: SelectRenderable | null = null;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand All @@ -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",
Expand All @@ -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",
},
{
Expand Down Expand Up @@ -710,27 +727,34 @@ 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 =
"↑/↓ navigate β€’ Enter open β€’ o folder β€’ d delete β€’ n new β€’ b branch β€’ c config β€’ q quit";
}
}

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;
Expand All @@ -741,6 +765,8 @@ class WorktreeSelector {
this.hookOutputContainer = null;
this.hookOutputText = null;
}

return worktreePath;
}

// ========== Config Editor Methods ==========
Expand Down Expand Up @@ -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);
});

Expand Down
Loading