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
*/
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
import type { CentralCore } from "./central-core.js";
@@ -255,13 +255,34 @@ export class FirstRunDetector {
if (name) return name;
}
} 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
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.
*

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
- **Bidirectional Communication**: WebSocket connection for instant input/output
- **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
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops

View File

@@ -740,7 +740,8 @@ export function SettingsModal({
useEffect(() => {
if (activeSection !== "remote") return;
if (remoteStatus?.state !== "stopped" || !externalTunnel?.url) {
const tunnelUrl = externalTunnel?.url;
if (remoteStatus?.state !== "stopped" || !tunnelUrl) {
return;
}
@@ -749,10 +750,10 @@ export function SettingsModal({
try {
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
if (cancelled) return;
setTunnelShareLink({ url: externalTunnel.url, qrSvg: qr.data ?? null });
setTunnelShareLink({ url: tunnelUrl, qrSvg: qr.data ?? null });
} catch {
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 MIN_TERMINAL_FONT_SIZE = 8;
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 {
if (!key) {
@@ -528,7 +530,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
cursorBlink: true,
cursorStyle: "block",
fontSize: fontSizeRef.current,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontFamily: XTERM_FONT_FAMILY,
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",

View File

@@ -551,6 +551,24 @@ describe("TerminalModal", () => {
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", () => {
it("is hidden by default and toggles from header action", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);

View File

@@ -1,5 +1,5 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import {
resolveProjectDefaultModel,
type TaskStore,
@@ -15,9 +15,25 @@ import { createLogger } from "./logger.js";
import { defaultShell } from "./shell-utils.js";
import { createFnAgent, promptWithFallback } from "./pi.js";
const execAsync = promisify(exec);
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. */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
/** Maximum output buffer: 1 MB. */
@@ -273,7 +289,7 @@ export class CronRunner {
try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execAsync(schedule.command, {
const { stdout, stderr } = await execCommand(schedule.command, {
timeout: timeoutMs,
maxBuffer: MAX_BUFFER,
shell: defaultShell,
@@ -428,7 +444,7 @@ export class CronRunner {
}
try {
const { stdout, stderr } = await execAsync(step.command, {
const { stdout, stderr } = await execCommand(step.command, {
timeout: timeoutMs,
maxBuffer: MAX_BUFFER,
shell: defaultShell,
@@ -523,11 +539,12 @@ export class CronRunner {
const response = await Promise.race([resultPromise, timeoutPromise]);
const output = response.length > MAX_OUTPUT_LENGTH
? response.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]"
: response;
const responseText = String(response ?? "");
const output = responseText.length > MAX_OUTPUT_LENGTH
? 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 {
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. */
function truncateOutput(stdout: string, stderr: string): string {
let combined = stdout;
if (stderr) {
function truncateOutput(stdout: string | null | undefined, stderr: string | null | undefined): string {
const out = stdout ?? "";
const err = stderr ?? "";
let combined = out;
if (err) {
// Add separator only if there's also stdout content
combined += stdout ? "\n--- stderr ---\n" : "";
combined += stderr;
combined += out ? "\n--- stderr ---\n" : "";
combined += err;
}
if (combined.length > MAX_OUTPUT_LENGTH) {
combined = combined.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]";

View File

@@ -1115,6 +1115,7 @@ export class TaskExecutor {
private async performWorkflowRerunBounce(
taskId: string,
worktreePath: string,
preserveResumeState: boolean = true,
): Promise<"bounced" | "skipped-pending"> {
// Re-entry guard: if a previous bounce for the same task is still
// 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
// pending and rewrites PROMPT.md checkboxes, which would discard
// 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, {
worktree: worktreePath,
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);
setTimeout(async () => {
try {
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath);
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
if (outcome === "bounced") {
executorLog.log(successMessage);
} else {
@@ -1204,7 +1214,7 @@ export class TaskExecutor {
).catch(() => undefined);
try {
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath);
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
if (outcome === "bounced") {
executorLog.warn(`${taskId}: workflow rerun watchdog retry succeeded`);
} else {
@@ -1434,7 +1444,7 @@ export class TaskExecutor {
if (!workflowResult.allPassed) {
// For recovery path, treat any failure (including revision) as hard failure
// 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
}
} else {
@@ -3197,8 +3207,19 @@ export class TaskExecutor {
};
}
const task = await store.updateStep(taskId, step, status as StepStatus);
const stepInfo = task.steps[step];
const stepIndex = step - 1;
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 progress = task.steps.filter((s) => s.status === "done").length;
@@ -3854,6 +3875,7 @@ ${feedback}
failureFeedback: string,
stepName: string,
reason: string,
preserveResumeState: boolean = true,
): Promise<void> {
const taskId = task.id;
this.clearCompletedTaskWatchdog(taskId);
@@ -3895,6 +3917,7 @@ ${feedback}
taskId,
worktreePath,
`${taskId}: sent back to in-progress for remediation`,
preserveResumeState,
);
}

View File

@@ -7,6 +7,15 @@ import { worktreePoolLog } from "./logger.js";
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> {
try {
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>> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
const result = await execAsync("git worktree list --porcelain", {
cwd: rootDir,
encoding: "utf-8",
});
const stdout = getExecStdout(result);
const paths = new Set<string>();
for (const line of stdout.split("\n")) {
@@ -191,58 +201,6 @@ export class WorktreePool {
// Remove untracked files (but not .gitignore'd build caches)
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";
await execAsync(`git checkout --detach ${base}`, {
cwd: worktreePath,
@@ -537,10 +495,11 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
// List all local branches matching fusion/*
let allBranches: string[];
try {
const { stdout } = await execAsync("git branch --list 'fusion/*'", {
const result = await execAsync("git branch --list 'fusion/*'", {
cwd: rootDir,
encoding: "utf-8",
});
const stdout = getExecStdout(result);
allBranches = stdout
.split("\n")
.map((line) => line.trim().replace(/^\*?\s*/, ""))