fix: atomic writes, parse radix, lossy json, silent worker spawn
8 fixes from 3rd-pass scan:
1. web/components/sf/tempCodeRunnerFile.tsx: remove orphan VS Code
'Code Runner' artifact (850+ lines duplicated from shell-terminal.tsx).
Unreferenced but compiled into tsc project.
2. sf/phase-anchor.ts: writePhaseAnchor used plain writeFileSync — a crash
mid-write would corrupt the handoff checkpoint that readPhaseAnchor then
silently returns null for, losing cross-phase context. Switched to
atomicWriteSync (already used by sibling files).
3. sf/forensics.ts: same non-atomic writeFileSync on active-forensics.json
marker. Race with a concurrent reader produces an empty object and the
forensics session is lost. Switched to atomicWriteSync.
4. web/auto-dashboard-service.ts: paused-session.json existence was the
intended signal but a corrupt body silently dropped the paused flag so
the UI showed active. Now reports paused on file existence regardless
of body integrity, and warns on corruption.
5. sf/visualizer-data.ts: doctor-history.jsonl parser did .map(JSON.parse)
inside an outer catch. One corrupt line discarded 19 valid entries.
Per-line try/catch preserves the valid rows.
6. sf/files.ts: three parseInt calls without radix (step, total_steps,
totalSteps) — also missing || 0 fallback for NaN.
7. cli.ts: parseInt(process.versions.node) without radix. Split on '.' and
use radix 10 explicitly.
8. sf/slice-parallel-orchestrator.ts: silent 'catch {}' around spawn()
masked worker-spawn failures as 'no workers available'. Matches sibling
parallel-orchestrator.ts pattern — now logs via logWarning.
Skipped from the scan (need a real lock mechanism, not safe as a one-line
fix):
- sf/auto-dispatch.ts:164 (UAT counter race)
- sf/captures.ts:107 (CAPTURES.md append race)
Deferred (low-value):
- preferences-models.ts, key-manager.ts, auto-timers.ts silent catches
- dead variable in visualizer-data.ts
- google-gemini-cli.ts maxTokens clamp interaction
tsc --noEmit green at root.
This commit is contained in:
parent
51b65fd490
commit
c744bdf6c1
8 changed files with 26 additions and 798 deletions
|
|
@ -39,7 +39,7 @@ import { loadEffectiveSFPreferences } from './resources/extensions/sf/preference
|
|||
// eliminating repeated parse/compile overhead for unchanged modules.
|
||||
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (parseInt(process.versions.node) >= 22) {
|
||||
if (parseInt(process.versions.node.split('.')[0], 10) >= 22) {
|
||||
process.env.NODE_COMPILE_CACHE ??= join(agentDir, '.compile-cache')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -390,9 +390,9 @@ function _parseContinueImpl(content: string): Continue {
|
|||
milestone: (fm.milestone as string) || '',
|
||||
slice: (fm.slice as string) || '',
|
||||
task: (fm.task as string) || '',
|
||||
step: typeof fm.step === 'string' ? parseInt(fm.step) : (fm.step as number) || 0,
|
||||
totalSteps: typeof fm.total_steps === 'string' ? parseInt(fm.total_steps) : (fm.total_steps as number) ||
|
||||
(typeof fm.totalSteps === 'string' ? parseInt(fm.totalSteps) : (fm.totalSteps as number) || 0),
|
||||
step: typeof fm.step === 'string' ? parseInt(fm.step, 10) || 0 : (fm.step as number) || 0,
|
||||
totalSteps: typeof fm.total_steps === 'string' ? parseInt(fm.total_steps, 10) || 0 : (fm.total_steps as number) ||
|
||||
(typeof fm.totalSteps === 'string' ? parseInt(fm.totalSteps, 10) || 0 : (fm.totalSteps as number) || 0),
|
||||
status: ((fm.status as string) || 'in_progress') as ContinueStatus,
|
||||
savedAt: (fm.saved_at as string) || (fm.savedAt as string) || '',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname, relative } from "node:path";
|
||||
import { atomicWriteSync } from "./atomic-write.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
|
|
@ -1028,7 +1029,7 @@ export function writeForensicsMarker(basePath: string, reportPath: string, promp
|
|||
promptContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(join(dir, "active-forensics.json"), JSON.stringify(marker), "utf-8");
|
||||
atomicWriteSync(join(dir, "active-forensics.json"), JSON.stringify(marker));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
* and intent without re-inferring from scratch.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { atomicWriteSync } from "./atomic-write.js";
|
||||
import { sfRoot } from "./paths.js";
|
||||
|
||||
export interface PhaseAnchor {
|
||||
|
|
@ -31,7 +32,7 @@ export function writePhaseAnchor(basePath: string, milestoneId: string, anchor:
|
|||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(anchorPath(basePath, milestoneId, anchor.phase), JSON.stringify(anchor, null, 2), "utf-8");
|
||||
atomicWriteSync(anchorPath(basePath, milestoneId, anchor.phase), JSON.stringify(anchor, null, 2));
|
||||
}
|
||||
|
||||
export function readPhaseAnchor(basePath: string, milestoneId: string, phase: string): PhaseAnchor | null {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { hasFileConflict } from "./slice-parallel-conflict.js";
|
||||
import { getErrorMessage } from "./error-utils.js";
|
||||
import { selectConflictFreeBatch } from "./uok/execution-graph.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -343,7 +344,8 @@ function spawnSliceWorker(
|
|||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logWarning("parallel", `slice worker spawn failed: ${(e as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -638,13 +638,18 @@ function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null, basePath
|
|||
environmentIssues = runEnvironmentChecks(basePath).filter(r => r.status !== "ok");
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
// Doctor run history — persisted across sessions (sync read to keep loadHealth sync)
|
||||
// Doctor run history — persisted across sessions (sync read to keep loadHealth sync).
|
||||
// Parse each line independently so a single corrupt row doesn't discard the
|
||||
// surrounding 19 valid entries.
|
||||
let doctorHistory: VisualizerDoctorEntry[] = [];
|
||||
try {
|
||||
const historyPath = join(sfRoot(basePath), "doctor-history.jsonl");
|
||||
if (existsSync(historyPath)) {
|
||||
const lines = readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim());
|
||||
doctorHistory = lines.slice(-20).reverse().map(l => JSON.parse(l) as VisualizerDoctorEntry);
|
||||
doctorHistory = lines.slice(-20).reverse().flatMap(l => {
|
||||
try { return [JSON.parse(l) as VisualizerDoctorEntry]; }
|
||||
catch { return []; }
|
||||
});
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
|
|
|
|||
|
|
@ -73,15 +73,18 @@ function reconcileWithDiskState(
|
|||
if (data.active || data.paused) return data;
|
||||
|
||||
// Check for paused-session.json first (paused takes precedence).
|
||||
// The file's *existence* is the signal — content is informational. If the
|
||||
// content is corrupt, still report paused (safer than silently showing active).
|
||||
const pausedPath = join(projectCwd, ".sf", "runtime", "paused-session.json");
|
||||
if (checkExists(pausedPath)) {
|
||||
try {
|
||||
// Validate the file is readable JSON (not corrupt).
|
||||
JSON.parse(readFileSync(pausedPath, "utf-8"));
|
||||
return { ...data, paused: true };
|
||||
} catch {
|
||||
// Corrupt or unreadable — ignore.
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[auto-dashboard] paused-session.json corrupt: ${err instanceof Error ? err.message : String(err)} — still reporting paused`,
|
||||
);
|
||||
}
|
||||
return { ...data, paused: true };
|
||||
}
|
||||
|
||||
// Check for session lock with a live PID.
|
||||
|
|
|
|||
|
|
@ -1,784 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { validateImageFile } from "@/lib/image-utils"
|
||||
import { filterInitialSfHeader } from "@/lib/initial-sf-header-filter"
|
||||
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
|
||||
import { authFetch, appendAuthParam } from "@/lib/auth"
|
||||
import "@xterm/xterm/css/xterm.css"
|
||||
|
||||
type XTerminal = import("@xterm/xterm").Terminal
|
||||
type XFitAddon = import("@xterm/addon-fit").FitAddon
|
||||
|
||||
const MIN_TERMINAL_ATTACH_WIDTH = 180
|
||||
const MIN_TERMINAL_ATTACH_HEIGHT = 120
|
||||
const MIN_TERMINAL_ATTACH_COLS = 20
|
||||
const MIN_TERMINAL_ATTACH_ROWS = 8
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TerminalTab {
|
||||
id: string
|
||||
label: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface ShellTerminalProps {
|
||||
className?: string
|
||||
command?: string
|
||||
commandArgs?: string[]
|
||||
sessionPrefix?: string
|
||||
hideSidebar?: boolean
|
||||
fontSize?: number
|
||||
hideInitialSfHeader?: boolean
|
||||
projectCwd?: string
|
||||
}
|
||||
|
||||
// ─── xterm themes ─────────────────────────────────────────────────────────────
|
||||
|
||||
const XTERM_DARK_THEME = {
|
||||
background: "#0a0a0a",
|
||||
foreground: "#e4e4e7",
|
||||
cursor: "#e4e4e7",
|
||||
cursorAccent: "#0a0a0a",
|
||||
selectionBackground: "#27272a",
|
||||
selectionForeground: "#e4e4e7",
|
||||
black: "#18181b",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
blue: "#3b82f6",
|
||||
magenta: "#a855f7",
|
||||
cyan: "#06b6d4",
|
||||
white: "#e4e4e7",
|
||||
brightBlack: "#52525b",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#facc15",
|
||||
brightBlue: "#60a5fa",
|
||||
brightMagenta: "#c084fc",
|
||||
brightCyan: "#22d3ee",
|
||||
brightWhite: "#fafafa",
|
||||
} as const
|
||||
|
||||
const XTERM_LIGHT_THEME = {
|
||||
background: "#f5f5f5",
|
||||
foreground: "#1a1a1a",
|
||||
cursor: "#1a1a1a",
|
||||
cursorAccent: "#f5f5f5",
|
||||
selectionBackground: "#d4d4d8",
|
||||
selectionForeground: "#1a1a1a",
|
||||
black: "#1a1a1a",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#e4e4e7",
|
||||
brightBlack: "#71717a",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#fafafa",
|
||||
} as const
|
||||
|
||||
function getXtermTheme(isDark: boolean) {
|
||||
return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME
|
||||
}
|
||||
|
||||
function getXtermOptions(isDark: boolean, fontSize?: number) {
|
||||
return {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar" as const,
|
||||
fontSize: fontSize ?? 13,
|
||||
fontFamily:
|
||||
"'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
lineHeight: 1.35,
|
||||
letterSpacing: 0,
|
||||
theme: getXtermTheme(isDark),
|
||||
allowProposedApi: true,
|
||||
scrollback: 10000,
|
||||
convertEol: false,
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
|
||||
if (!container || !terminal) return null
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width < MIN_TERMINAL_ATTACH_WIDTH || rect.height < MIN_TERMINAL_ATTACH_HEIGHT) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (terminal.cols < MIN_TERMINAL_ATTACH_COLS || terminal.rows < MIN_TERMINAL_ATTACH_ROWS) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { cols: terminal.cols, rows: terminal.rows }
|
||||
}
|
||||
|
||||
async function settleTerminalLayout(
|
||||
container: HTMLDivElement | null,
|
||||
terminal: XTerminal | null,
|
||||
fitAddon: XFitAddon | null,
|
||||
isDisposed: () => boolean,
|
||||
): Promise<{ cols: number; rows: number } | null> {
|
||||
if (typeof document !== "undefined" && "fonts" in document) {
|
||||
try {
|
||||
await Promise.race([
|
||||
document.fonts.ready,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
|
||||
])
|
||||
} catch {
|
||||
// Ignore font loading failures and fall through to repeated fit attempts.
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
if (isDisposed()) return null
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
|
||||
if (isDisposed()) return null
|
||||
|
||||
try {
|
||||
fitAddon?.fit()
|
||||
} catch {
|
||||
/* hidden or detached */
|
||||
}
|
||||
|
||||
const size = getRenderableTerminalSize(container, terminal)
|
||||
if (size) {
|
||||
return size
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
return getRenderableTerminalSize(container, terminal)
|
||||
}
|
||||
|
||||
function deriveCommandLabel(command?: string): string {
|
||||
if (!command?.trim()) return "zsh"
|
||||
const token = command.trim().split(/\s+/)[0] || command
|
||||
const normalized = token.replace(/\\/g, "/")
|
||||
const parts = normalized.split("/")
|
||||
return parts[parts.length - 1] || token
|
||||
}
|
||||
|
||||
// ─── Single terminal instance (internal) ──────────────────────────────────────
|
||||
|
||||
interface TerminalInstanceProps {
|
||||
sessionId: string
|
||||
visible: boolean
|
||||
command?: string
|
||||
commandArgs?: string[]
|
||||
isDark: boolean
|
||||
fontSize?: number
|
||||
hideInitialSfHeader?: boolean
|
||||
projectCwd?: string
|
||||
onConnectionChange: (connected: boolean) => void
|
||||
}
|
||||
|
||||
function TerminalInstance({
|
||||
sessionId,
|
||||
visible,
|
||||
command,
|
||||
commandArgs,
|
||||
isDark,
|
||||
fontSize,
|
||||
hideInitialSfHeader = false,
|
||||
projectCwd,
|
||||
onConnectionChange,
|
||||
}: TerminalInstanceProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<XTerminal | null>(null)
|
||||
const fitAddonRef = useRef<XFitAddon | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const inputQueueRef = useRef<string[]>([])
|
||||
const flushingRef = useRef(false)
|
||||
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const onConnectionChangeRef = useRef(onConnectionChange)
|
||||
const initialHeaderSettledRef = useRef(!hideInitialSfHeader)
|
||||
const initialHeaderBufferRef = useRef("")
|
||||
const commandArgsKey = (commandArgs ?? []).join("\u0000")
|
||||
const [hasOutput, setHasOutput] = useState(false)
|
||||
|
||||
const sendResize = useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
void authFetch(buildProjectPath("/api/terminal/resize", projectCwd), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: sessionId, cols, rows }),
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
[projectCwd, sessionId],
|
||||
)
|
||||
|
||||
const flushInputQueue = useCallback(async () => {
|
||||
if (flushingRef.current) return
|
||||
flushingRef.current = true
|
||||
while (inputQueueRef.current.length > 0) {
|
||||
const data = inputQueueRef.current.shift()!
|
||||
try {
|
||||
await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: sessionId, data }),
|
||||
})
|
||||
} catch {
|
||||
inputQueueRef.current.unshift(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
flushingRef.current = false
|
||||
}, [projectCwd, sessionId])
|
||||
|
||||
const sendInput = useCallback(
|
||||
(data: string) => {
|
||||
inputQueueRef.current.push(data)
|
||||
void flushInputQueue()
|
||||
},
|
||||
[flushInputQueue],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onConnectionChangeRef.current = onConnectionChange
|
||||
}, [onConnectionChange])
|
||||
|
||||
useEffect(() => {
|
||||
initialHeaderSettledRef.current = !hideInitialSfHeader
|
||||
initialHeaderBufferRef.current = ""
|
||||
}, [hideInitialSfHeader, sessionId])
|
||||
|
||||
// Update xterm theme when isDark changes
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.theme = getXtermTheme(isDark)
|
||||
}
|
||||
}, [isDark])
|
||||
|
||||
// Update xterm font size when fontSize changes
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = fontSize ?? 13
|
||||
try {
|
||||
fitAddonRef.current?.fit()
|
||||
if (termRef.current) {
|
||||
sendResize(termRef.current.cols, termRef.current.rows)
|
||||
}
|
||||
} catch {
|
||||
/* not visible yet */
|
||||
}
|
||||
}
|
||||
}, [fontSize, sendResize])
|
||||
|
||||
// Re-fit when visibility changes
|
||||
useEffect(() => {
|
||||
if (visible && fitAddonRef.current && termRef.current) {
|
||||
// Small delay to let the DOM settle
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit()
|
||||
if (termRef.current) {
|
||||
sendResize(termRef.current.cols, termRef.current.rows)
|
||||
}
|
||||
} catch {
|
||||
/* not visible yet */
|
||||
}
|
||||
}, 50)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [visible, sendResize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
let disposed = false
|
||||
let terminal: XTerminal | null = null
|
||||
let fitAddon: XFitAddon | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const init = async () => {
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
])
|
||||
|
||||
if (disposed) return
|
||||
|
||||
terminal = new Terminal(getXtermOptions(isDark, fontSize))
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(containerRef.current!)
|
||||
|
||||
termRef.current = terminal
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
|
||||
if (disposed) return
|
||||
|
||||
terminal.onData((data) => sendInput(data))
|
||||
terminal.onBinary((data) => sendInput(data))
|
||||
|
||||
// SSE stream
|
||||
const streamUrl = buildProjectAbsoluteUrl(
|
||||
"/api/terminal/stream",
|
||||
window.location.origin,
|
||||
projectCwd,
|
||||
)
|
||||
streamUrl.searchParams.set("id", sessionId)
|
||||
if (command) streamUrl.searchParams.set("command", command)
|
||||
for (const arg of commandArgs ?? []) {
|
||||
streamUrl.searchParams.append("arg", arg)
|
||||
}
|
||||
const es = new EventSource(appendAuthParam(streamUrl.toString()))
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as {
|
||||
type: string
|
||||
data?: string
|
||||
}
|
||||
if (msg.type === "connected") {
|
||||
onConnectionChangeRef.current(true)
|
||||
void settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed).then((size) => {
|
||||
if (!size) return
|
||||
sendResize(size.cols, size.rows)
|
||||
})
|
||||
} else if (msg.type === "output" && msg.data) {
|
||||
let output = msg.data
|
||||
|
||||
if (hideInitialSfHeader && !initialHeaderSettledRef.current) {
|
||||
initialHeaderBufferRef.current += output
|
||||
const filtered = filterInitialSfHeader(initialHeaderBufferRef.current)
|
||||
|
||||
if (filtered.status === "needs-more") {
|
||||
return
|
||||
}
|
||||
|
||||
initialHeaderSettledRef.current = true
|
||||
initialHeaderBufferRef.current = ""
|
||||
output = filtered.text
|
||||
}
|
||||
|
||||
if (output) {
|
||||
terminal?.write(output)
|
||||
setHasOutput(true)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* malformed */
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => onConnectionChangeRef.current(false)
|
||||
|
||||
// Resize observer
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (disposed) return
|
||||
try {
|
||||
fitAddon?.fit()
|
||||
if (terminal) sendResize(terminal.cols, terminal.rows)
|
||||
} catch {
|
||||
/* not visible */
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.current!)
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
|
||||
eventSourceRef.current?.close()
|
||||
eventSourceRef.current = null
|
||||
resizeObserver?.disconnect()
|
||||
terminal?.dispose()
|
||||
termRef.current = null
|
||||
fitAddonRef.current = null
|
||||
}
|
||||
}, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialSfHeader, isDark, projectCwd, sendInput, sendResize])
|
||||
|
||||
// Focus on click
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const handleClick = useCallback(() => {
|
||||
termRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Shift+Enter → newline (native DOM, capture phase)
|
||||
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
|
||||
// recognizes \n (LF) as "insert newline".
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current
|
||||
if (!el) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendInput("\n")
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("keydown", onKeyDown, true)
|
||||
return () => el.removeEventListener("keydown", onKeyDown, true)
|
||||
}, [sendInput])
|
||||
|
||||
// Auto-focus when this tab becomes visible
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Small delay to let layout settle
|
||||
const t = setTimeout(() => termRef.current?.focus(), 80)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("relative h-full w-full bg-terminal", !visible && "hidden")}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Loading overlay — visible until first output arrives */}
|
||||
{!hasOutput && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{command ? "Starting SF…" : "Connecting…"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ padding: "8px 4px 4px 8px" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Image upload helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
])
|
||||
|
||||
/**
|
||||
* Upload an image file to the server's temp directory and inject the `@filepath`
|
||||
* text into the PTY session's stdin.
|
||||
*
|
||||
* Observability:
|
||||
* - console.warn on client-side validation failure
|
||||
* - console.error on upload or inject failure
|
||||
*/
|
||||
async function uploadAndInjectImage(file: File, sessionId: string, projectCwd?: string): Promise<void> {
|
||||
// Client-side validation
|
||||
const validation = validateImageFile(file)
|
||||
if (!validation.valid) {
|
||||
console.warn("[terminal-upload] validation failed:", validation.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Upload to temp dir
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
let uploadPath: string
|
||||
try {
|
||||
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json() as { ok?: boolean; path?: string; error?: string }
|
||||
if (!res.ok || !data.path) {
|
||||
console.error("[terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
uploadPath = data.path
|
||||
} catch (err) {
|
||||
console.error("[terminal-upload] upload request failed:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Inject @filepath into PTY stdin
|
||||
try {
|
||||
const res = await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: sessionId, data: `@${uploadPath} ` }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({})) as { error?: string }
|
||||
console.error("[terminal-upload] inject failed:", data.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[terminal-upload] inject request failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Multi-instance terminal panel ────────────────────────────────────────────
|
||||
|
||||
export function ShellTerminal({
|
||||
className,
|
||||
command,
|
||||
commandArgs,
|
||||
sessionPrefix,
|
||||
hideSidebar = false,
|
||||
fontSize,
|
||||
hideInitialSfHeader = false,
|
||||
projectCwd,
|
||||
}: ShellTerminalProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme !== "light"
|
||||
const defaultId = sessionPrefix ?? (command ? "sf-default" : "default")
|
||||
const commandLabel = deriveCommandLabel(command)
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>([
|
||||
{ id: defaultId, label: commandLabel, connected: false },
|
||||
])
|
||||
const [activeTabId, setActiveTabId] = useState(defaultId)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const terminalAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// ── Drag-and-drop handlers (native DOM, capture phase) ──────────────────
|
||||
// React synthetic events don't reliably fire through xterm's internal DOM.
|
||||
// Native capture-phase listeners intercept before xterm can swallow them —
|
||||
// same pattern used for paste below.
|
||||
|
||||
useEffect(() => {
|
||||
const el = terminalAreaRef.current
|
||||
if (!el) return
|
||||
|
||||
let counter = 0
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
counter += 1
|
||||
if (counter === 1) setIsDragOver(true)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const onDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
counter -= 1
|
||||
if (counter <= 0) {
|
||||
counter = 0
|
||||
setIsDragOver(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
counter = 0
|
||||
setIsDragOver(false)
|
||||
|
||||
if (!activeTabId) return
|
||||
const files = Array.from(e.dataTransfer?.files ?? [])
|
||||
const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type))
|
||||
if (imageFile) {
|
||||
void uploadAndInjectImage(imageFile, activeTabId, projectCwd)
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("dragenter", onDragEnter, true)
|
||||
el.addEventListener("dragover", onDragOver, true)
|
||||
el.addEventListener("dragleave", onDragLeave, true)
|
||||
el.addEventListener("drop", onDrop, true)
|
||||
return () => {
|
||||
el.removeEventListener("dragenter", onDragEnter, true)
|
||||
el.removeEventListener("dragover", onDragOver, true)
|
||||
el.removeEventListener("dragleave", onDragLeave, true)
|
||||
el.removeEventListener("drop", onDrop, true)
|
||||
}
|
||||
}, [activeTabId, projectCwd])
|
||||
|
||||
// ── Paste handler for images ──────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const el = terminalAreaRef.current
|
||||
if (!el) return
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (!e.clipboardData) return
|
||||
const files = Array.from(e.clipboardData.files)
|
||||
const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type))
|
||||
if (imageFile) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (activeTabId) {
|
||||
void uploadAndInjectImage(imageFile, activeTabId, projectCwd)
|
||||
}
|
||||
}
|
||||
// If no image files, don't prevent default — let xterm.js handle text paste
|
||||
}
|
||||
|
||||
el.addEventListener("paste", handlePaste, true) // capture phase to fire before xterm
|
||||
return () => el.removeEventListener("paste", handlePaste, true)
|
||||
}, [activeTabId, projectCwd])
|
||||
|
||||
const createTab = useCallback(async () => {
|
||||
try {
|
||||
const res = await authFetch(buildProjectPath("/api/terminal/sessions", projectCwd), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(command ? { command } : {}),
|
||||
})
|
||||
const data = (await res.json()) as { id: string }
|
||||
const newTab: TerminalTab = {
|
||||
id: data.id,
|
||||
label: commandLabel,
|
||||
connected: false,
|
||||
}
|
||||
setTabs((prev) => [...prev, newTab])
|
||||
setActiveTabId(data.id)
|
||||
} catch {
|
||||
/* network error */
|
||||
}
|
||||
}, [command, commandLabel, projectCwd])
|
||||
|
||||
const closeTab = useCallback(
|
||||
(id: string) => {
|
||||
// Don't close the last tab
|
||||
if (tabs.length <= 1) return
|
||||
const deleteUrl = buildProjectAbsoluteUrl("/api/terminal/sessions", window.location.origin, projectCwd)
|
||||
deleteUrl.searchParams.set("id", id)
|
||||
void authFetch(deleteUrl.toString(), {
|
||||
method: "DELETE",
|
||||
})
|
||||
const remaining = tabs.filter((t) => t.id !== id)
|
||||
setTabs(remaining)
|
||||
if (activeTabId === id) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? defaultId)
|
||||
}
|
||||
},
|
||||
[tabs, activeTabId, defaultId, projectCwd],
|
||||
)
|
||||
|
||||
const updateConnection = useCallback(
|
||||
(id: string, connected: boolean) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, connected } : t)),
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn("flex bg-terminal", className)}>
|
||||
{/* Terminal area — receives drag/drop and paste for images */}
|
||||
<div
|
||||
ref={terminalAreaRef}
|
||||
className="relative flex-1 min-w-0"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TerminalInstance
|
||||
key={tab.id}
|
||||
sessionId={tab.id}
|
||||
visible={tab.id === activeTabId}
|
||||
command={command}
|
||||
commandArgs={tab.id === defaultId ? commandArgs : undefined}
|
||||
isDark={isDark}
|
||||
fontSize={fontSize}
|
||||
hideInitialSfHeader={hideInitialSfHeader}
|
||||
projectCwd={projectCwd}
|
||||
onConnectionChange={(c) => updateConnection(tab.id, c)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Drop overlay */}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background/80 backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
|
||||
<ImagePlus className="h-8 w-8 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">Drop image here</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideSidebar && (
|
||||
<div className="flex w-[34px] flex-shrink-0 flex-col border-l border-border/40 bg-terminal">
|
||||
{/* New terminal button */}
|
||||
<button
|
||||
onClick={createTab}
|
||||
className="flex h-[30px] w-full items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
title="New terminal"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-border/40" />
|
||||
|
||||
{/* Tab list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
className={cn(
|
||||
"group relative flex h-[30px] w-full items-center justify-center transition-colors",
|
||||
tab.id === activeTabId
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
|
||||
)}
|
||||
title={`${tab.label} ${index + 1}`}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{tab.id === activeTabId && (
|
||||
<div className="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center">
|
||||
<TerminalSquare className="h-3 w-3" />
|
||||
{/* Connection dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border border-terminal",
|
||||
tab.connected ? "bg-success" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close button — shows on hover as small badge in corner */}
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}
|
||||
className="absolute -right-0.5 -top-0.5 z-10 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-accent text-muted-foreground hover:bg-destructive/20 hover:text-destructive group-hover:flex"
|
||||
title="Kill terminal"
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue