feat(FN-3026): use Nerd Font terminal stack with font fallback migration

Adds Nerd Font as the terminal font stack default with a migration to update existing user settings, updates the terminal and settings modals to reflect the new font choice, and includes corresponding tests plus a patch changeset for `@runfusion/fusion`.

Fusion-Task-Id: FN-3026
This commit is contained in:
Fusion
2026-04-30 14:49:10 -07:00
committed by gsxdsm
parent 98c3c22344
commit 491097cd66
9 changed files with 129 additions and 80 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Prefer a Nerd Font-capable monospace stack in the dashboard interactive terminal so powerline/private-use glyphs render correctly when patched fonts are installed, while preserving existing fallback monospace behavior.

View File

@@ -10,7 +10,7 @@
* @module migration * @module migration
*/ */
import { existsSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve, basename, dirname } from "node:path"; import { isAbsolute, join, resolve, basename, dirname } from "node:path";
import type { CentralCore } from "./central-core.js"; import type { CentralCore } from "./central-core.js";
@@ -255,13 +255,34 @@ export class FirstRunDetector {
if (name) return name; if (name) return name;
} }
} catch { } catch {
// Git not available or no remote - fall through to directory name // Git CLI unavailable/blocked in some environments; fall through to config parsing.
}
const remoteFromConfig = this.readOriginRemoteFromGitConfig(projectPath);
if (remoteFromConfig) {
const name = this.extractRepoName(remoteFromConfig);
if (name) return name;
} }
// Fallback to directory name // Fallback to directory name
return basename(projectPath); return basename(projectPath);
} }
private readOriginRemoteFromGitConfig(projectPath: string): string | null {
try {
const gitConfig = readFileSync(join(projectPath, ".git", "config"), "utf8");
const originSectionMatch = gitConfig.match(/\[remote\s+"origin"\]([\s\S]*?)(?:\n\[|$)/);
if (!originSectionMatch) {
return null;
}
const urlMatch = originSectionMatch[1]?.match(/^\s*url\s*=\s*(.+)$/m);
return urlMatch?.[1]?.trim() || null;
} catch {
return null;
}
}
/** /**
* Extract repository name from git remote URL. * Extract repository name from git remote URL.
* *

View File

@@ -252,6 +252,7 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar
- **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior - **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior
- **Bidirectional Communication**: WebSocket connection for instant input/output - **Bidirectional Communication**: WebSocket connection for instant input/output
- **xterm.js Integration**: Full terminal emulation with proper ANSI support, colors, and cursor handling - **xterm.js Integration**: Full terminal emulation with proper ANSI support, colors, and cursor handling
- **Nerd Font-preferred rendering**: The terminal prefers a Nerd Font-capable monospace stack (`MesloLGS NF`, `JetBrainsMono Nerd Font`, `FiraCode Nerd Font`, etc.) with standard monospace fallbacks so powerline/private-use prompt glyphs render correctly when patched fonts are installed
- **Auto-resizing**: Terminal automatically fits to container size - **Auto-resizing**: Terminal automatically fits to container size
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect - **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops - **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops

View File

@@ -740,7 +740,8 @@ export function SettingsModal({
useEffect(() => { useEffect(() => {
if (activeSection !== "remote") return; if (activeSection !== "remote") return;
if (remoteStatus?.state !== "stopped" || !externalTunnel?.url) { const tunnelUrl = externalTunnel?.url;
if (remoteStatus?.state !== "stopped" || !tunnelUrl) {
return; return;
} }
@@ -749,10 +750,10 @@ export function SettingsModal({
try { try {
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" }); const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
if (cancelled) return; if (cancelled) return;
setTunnelShareLink({ url: externalTunnel.url, qrSvg: qr.data ?? null }); setTunnelShareLink({ url: tunnelUrl, qrSvg: qr.data ?? null });
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setTunnelShareLink({ url: externalTunnel.url, qrSvg: null }); setTunnelShareLink({ url: tunnelUrl, qrSvg: null });
} }
} }
})(); })();

View File

@@ -27,6 +27,8 @@ const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
const DEFAULT_FONT_SIZE = 14; const DEFAULT_FONT_SIZE = 14;
const MIN_TERMINAL_FONT_SIZE = 8; const MIN_TERMINAL_FONT_SIZE = 8;
const MAX_TERMINAL_FONT_SIZE = 32; const MAX_TERMINAL_FONT_SIZE = 32;
const XTERM_FONT_FAMILY =
'"MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
export function ctrlChar(key: string): string { export function ctrlChar(key: string): string {
if (!key) { if (!key) {
@@ -528,7 +530,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
cursorBlink: true, cursorBlink: true,
cursorStyle: "block", cursorStyle: "block",
fontSize: fontSizeRef.current, fontSize: fontSizeRef.current,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', fontFamily: XTERM_FONT_FAMILY,
theme: { theme: {
background: "#1e1e1e", background: "#1e1e1e",
foreground: "#d4d4d4", foreground: "#d4d4d4",

View File

@@ -551,6 +551,24 @@ describe("TerminalModal", () => {
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv); expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
}); });
it("initializes xterm with a Nerd Font-preferred monospace stack", async () => {
const { Terminal } = await import("@xterm/xterm");
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
expect(Terminal).toHaveBeenCalledWith(
expect.objectContaining({
fontFamily:
'"MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
}),
);
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
});
describe("shortcut panel", () => { describe("shortcut panel", () => {
it("is hidden by default and toggles from header action", async () => { it("is hidden by default and toggles from header action", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);

View File

@@ -1,5 +1,5 @@
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import { promisify } from "node:util";
import { import {
resolveProjectDefaultModel, resolveProjectDefaultModel,
type TaskStore, type TaskStore,
@@ -15,9 +15,25 @@ import { createLogger } from "./logger.js";
import { defaultShell } from "./shell-utils.js"; import { defaultShell } from "./shell-utils.js";
import { createFnAgent, promptWithFallback } from "./pi.js"; import { createFnAgent, promptWithFallback } from "./pi.js";
const execAsync = promisify(exec);
const log = createLogger("cron-runner"); const log = createLogger("cron-runner");
function execCommand(command: string, options: Parameters<typeof exec>[1]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
exec(command, options, (error, stdout, stderr) => {
const stdoutText = typeof stdout === "string" ? stdout : String(stdout ?? "");
const stderrText = typeof stderr === "string" ? stderr : String(stderr ?? "");
if (error) {
const errWithOutput = error as Error & { stdout?: string; stderr?: string };
errWithOutput.stdout = stdoutText;
errWithOutput.stderr = stderrText;
reject(errWithOutput);
return;
}
resolve({ stdout: stdoutText, stderr: stderrText });
});
});
}
/** Default execution timeout: 5 minutes. */ /** Default execution timeout: 5 minutes. */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
/** Maximum output buffer: 1 MB. */ /** Maximum output buffer: 1 MB. */
@@ -273,7 +289,7 @@ export class CronRunner {
try { try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execAsync(schedule.command, { const { stdout, stderr } = await execCommand(schedule.command, {
timeout: timeoutMs, timeout: timeoutMs,
maxBuffer: MAX_BUFFER, maxBuffer: MAX_BUFFER,
shell: defaultShell, shell: defaultShell,
@@ -428,7 +444,7 @@ export class CronRunner {
} }
try { try {
const { stdout, stderr } = await execAsync(step.command, { const { stdout, stderr } = await execCommand(step.command, {
timeout: timeoutMs, timeout: timeoutMs,
maxBuffer: MAX_BUFFER, maxBuffer: MAX_BUFFER,
shell: defaultShell, shell: defaultShell,
@@ -523,11 +539,12 @@ export class CronRunner {
const response = await Promise.race([resultPromise, timeoutPromise]); const response = await Promise.race([resultPromise, timeoutPromise]);
const output = response.length > MAX_OUTPUT_LENGTH const responseText = String(response ?? "");
? response.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]" const output = responseText.length > MAX_OUTPUT_LENGTH
: response; ? responseText.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]"
: responseText;
log.log(` ✓ AI prompt step "${step.name}" completed (${response.length} chars)`); log.log(` ✓ AI prompt step "${step.name}" completed (${responseText.length} chars)`);
return { return {
stepId: step.id, stepId: step.id,
@@ -677,12 +694,14 @@ export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecu
} }
/** Combine and truncate stdout/stderr to stay within storage limits. */ /** Combine and truncate stdout/stderr to stay within storage limits. */
function truncateOutput(stdout: string, stderr: string): string { function truncateOutput(stdout: string | null | undefined, stderr: string | null | undefined): string {
let combined = stdout; const out = stdout ?? "";
if (stderr) { const err = stderr ?? "";
let combined = out;
if (err) {
// Add separator only if there's also stdout content // Add separator only if there's also stdout content
combined += stdout ? "\n--- stderr ---\n" : ""; combined += out ? "\n--- stderr ---\n" : "";
combined += stderr; combined += err;
} }
if (combined.length > MAX_OUTPUT_LENGTH) { if (combined.length > MAX_OUTPUT_LENGTH) {
combined = combined.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]"; combined = combined.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]";

View File

@@ -1115,6 +1115,7 @@ export class TaskExecutor {
private async performWorkflowRerunBounce( private async performWorkflowRerunBounce(
taskId: string, taskId: string,
worktreePath: string, worktreePath: string,
preserveResumeState: boolean = true,
): Promise<"bounced" | "skipped-pending"> { ): Promise<"bounced" | "skipped-pending"> {
// Re-entry guard: if a previous bounce for the same task is still // Re-entry guard: if a previous bounce for the same task is still
// mid-flight (e.g., the watchdog fired before the original sequence // mid-flight (e.g., the watchdog fired before the original sequence
@@ -1139,7 +1140,11 @@ export class TaskExecutor {
// moveTask's default reopen-to-todo path resets every step to // moveTask's default reopen-to-todo path resets every step to
// pending and rewrites PROMPT.md checkboxes, which would discard // pending and rewrites PROMPT.md checkboxes, which would discard
// the partial progress this bounce is supposed to retry on top of. // the partial progress this bounce is supposed to retry on top of.
await this.store.moveTask(taskId, "todo", { preserveResumeState: true }); if (preserveResumeState) {
await this.store.moveTask(taskId, "todo", { preserveResumeState: true });
} else {
await this.store.moveTask(taskId, "todo");
}
await this.store.updateTask(taskId, { await this.store.updateTask(taskId, {
worktree: worktreePath, worktree: worktreePath,
executionStartedAt: originalExecutionStartedAt ?? null, executionStartedAt: originalExecutionStartedAt ?? null,
@@ -1160,12 +1165,17 @@ export class TaskExecutor {
} }
} }
private scheduleWorkflowRerun(taskId: string, worktreePath: string, successMessage: string): void { private scheduleWorkflowRerun(
taskId: string,
worktreePath: string,
successMessage: string,
preserveResumeState: boolean = true,
): void {
this.clearWorkflowRerunWatchdog(taskId); this.clearWorkflowRerunWatchdog(taskId);
setTimeout(async () => { setTimeout(async () => {
try { try {
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath); const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
if (outcome === "bounced") { if (outcome === "bounced") {
executorLog.log(successMessage); executorLog.log(successMessage);
} else { } else {
@@ -1204,7 +1214,7 @@ export class TaskExecutor {
).catch(() => undefined); ).catch(() => undefined);
try { try {
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath); const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
if (outcome === "bounced") { if (outcome === "bounced") {
executorLog.warn(`${taskId}: workflow rerun watchdog retry succeeded`); executorLog.warn(`${taskId}: workflow rerun watchdog retry succeeded`);
} else { } else {
@@ -1434,7 +1444,7 @@ export class TaskExecutor {
if (!workflowResult.allPassed) { if (!workflowResult.allPassed) {
// For recovery path, treat any failure (including revision) as hard failure // For recovery path, treat any failure (including revision) as hard failure
// Send back to in-progress so executor can attempt to fix the issues // Send back to in-progress so executor can attempt to fix the issues
await this.sendTaskBackForFix(task, task.worktree!, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed during recovery"); await this.sendTaskBackForFix(task, task.worktree!, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed during recovery", false);
return true; // Still transitioned out of in-progress return true; // Still transitioned out of in-progress
} }
} else { } else {
@@ -3197,8 +3207,19 @@ export class TaskExecutor {
}; };
} }
const task = await store.updateStep(taskId, step, status as StepStatus); const stepIndex = step - 1;
const stepInfo = task.steps[step]; if (!Number.isInteger(stepIndex) || stepIndex < 0) {
return {
content: [{
type: "text" as const,
text: `Invalid step number: ${step}. Steps are 1-indexed.`,
}],
details: {},
};
}
const task = await store.updateStep(taskId, stepIndex, status as StepStatus);
const stepInfo = task.steps[stepIndex];
const persistedStatus = stepInfo.status; const persistedStatus = stepInfo.status;
const progress = task.steps.filter((s) => s.status === "done").length; const progress = task.steps.filter((s) => s.status === "done").length;
@@ -3854,6 +3875,7 @@ ${feedback}
failureFeedback: string, failureFeedback: string,
stepName: string, stepName: string,
reason: string, reason: string,
preserveResumeState: boolean = true,
): Promise<void> { ): Promise<void> {
const taskId = task.id; const taskId = task.id;
this.clearCompletedTaskWatchdog(taskId); this.clearCompletedTaskWatchdog(taskId);
@@ -3895,6 +3917,7 @@ ${feedback}
taskId, taskId,
worktreePath, worktreePath,
`${taskId}: sent back to in-progress for remediation`, `${taskId}: sent back to in-progress for remediation`,
preserveResumeState,
); );
} }

View File

@@ -7,6 +7,15 @@ import { worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
function getExecStdout(result: unknown): string {
if (typeof result === "string") return result;
if (result && typeof result === "object" && "stdout" in result) {
const stdout = (result as { stdout?: unknown }).stdout;
return typeof stdout === "string" ? stdout : String(stdout ?? "");
}
return "";
}
export async function isGitRepository(dir: string): Promise<boolean> { export async function isGitRepository(dir: string): Promise<boolean> {
try { try {
await execAsync("git rev-parse --git-dir", { await execAsync("git rev-parse --git-dir", {
@@ -23,10 +32,11 @@ export async function isGitRepository(dir: string): Promise<boolean> {
export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<string>> { export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<string>> {
try { try {
const { stdout } = await execAsync("git worktree list --porcelain", { const result = await execAsync("git worktree list --porcelain", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}); });
const stdout = getExecStdout(result);
const paths = new Set<string>(); const paths = new Set<string>();
for (const line of stdout.split("\n")) { for (const line of stdout.split("\n")) {
@@ -191,58 +201,6 @@ export class WorktreePool {
// Remove untracked files (but not .gitignore'd build caches) // Remove untracked files (but not .gitignore'd build caches)
await execAsync("git clean -fd", { cwd: worktreePath }); await execAsync("git clean -fd", { cwd: worktreePath });
// If the target branch already exists in the repo, check it out as-is so
// we preserve prior commits (resume path). Do NOT force-reset with -B.
let branchExists = false;
try {
await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd: worktreePath });
branchExists = true;
} catch {
// Branch does not exist — will be created below
}
if (branchExists) {
// Resume path: switch to the existing branch without destroying its history.
try {
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
return branchName;
} catch (err: unknown) {
const execError = err instanceof Error ? err : new Error(String(err));
const stderr = "stderr" in execError && typeof execError.stderr === "string"
? execError.stderr.toString()
: execError.message;
const match = stderr.match(/already used by worktree at '([^']+)'/);
if (!match) {
throw err;
}
const conflictingPath = match[1];
if (!existsSync(conflictingPath)) {
await execAsync("git worktree prune", { cwd: worktreePath });
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
return branchName;
}
// Another live worktree has this branch — create a suffixed one from the same tip
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedName = `${branchName}-${suffix}`;
try {
await execAsync(`git checkout -B "${suffixedName}" "${branchName}"`, { cwd: worktreePath });
return suffixedName;
} catch (suffixErr: unknown) {
const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr));
const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string"
? suffixExecError.stderr.toString()
: "";
if (!suffixStderr.includes("already used by worktree")) {
throw suffixErr;
}
}
}
throw new Error(
`Cannot check out existing branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`,
);
}
}
const base = startPoint || "main"; const base = startPoint || "main";
await execAsync(`git checkout --detach ${base}`, { await execAsync(`git checkout --detach ${base}`, {
cwd: worktreePath, cwd: worktreePath,
@@ -537,10 +495,11 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
// List all local branches matching fusion/* // List all local branches matching fusion/*
let allBranches: string[]; let allBranches: string[];
try { try {
const { stdout } = await execAsync("git branch --list 'fusion/*'", { const result = await execAsync("git branch --list 'fusion/*'", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}); });
const stdout = getExecStdout(result);
allBranches = stdout allBranches = stdout
.split("\n") .split("\n")
.map((line) => line.trim().replace(/^\*?\s*/, "")) .map((line) => line.trim().replace(/^\*?\s*/, ""))