feat(FN-2648): merge fusion/fn-2648

This commit is contained in:
Fusion
2026-04-26 23:50:52 -07:00
committed by gsxdsm
parent b6c6798aac
commit 864d55065b
6 changed files with 94 additions and 7 deletions

View File

@@ -53,6 +53,7 @@ import { SECTION_ORDER } from "./state.js";
import type { LogEntry } from "./log-ring-buffer.js";
import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js";
import { useProjects, useTasks } from "./hooks/use-projects.js";
import { copyToClipboard } from "./utils.js";
// ── Format helpers ────────────────────────────────────────────────────────────
@@ -623,7 +624,7 @@ function LogsPanel({
function ExpandedLog({ entry, index, total }: { entry: LogEntry; index: number; total: number }) {
return (
<Box flexDirection="column" flexGrow={1} width="100%">
<Text dimColor>Entry {index + 1}/{total} · [Enter/Esc] close</Text>
<Text dimColor>Entry {index + 1}/{total} · [Enter/Esc] close · [c] copy</Text>
<Box height={1} />
<Box flexDirection="row" gap={1}>
<Text dimColor>Time:</Text>
@@ -698,6 +699,7 @@ function HelpOverlay() {
["[↑/↓/k/j]", "Navigate list / log entries"],
["[Home / G]", "First / last log entry (Logs)"],
["[Enter/Space]", "Expand log entry (Logs)"],
["[c]", "Copy selected log entry to clipboard (Logs)"],
["[w]", "Toggle word wrap (Logs / Files)"],
["[f]", "Cycle severity filter (Main, any panel)"],
["[Space]", "Toggle boolean (Settings)"],
@@ -4049,6 +4051,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
controller.setSelectedLogIndex(Math.max(0, filteredEntries.length - 1));
return;
}
if (input === "c" || input === "C") {
const target = filteredEntries[state.selectedLogIndex];
if (target) {
const ts = formatTimestamp(target.timestamp);
const prefix = target.prefix ? `[${target.prefix}] ` : "";
const text = `${ts} ${target.level.toUpperCase()} ${prefix}${target.message}`;
void copyToClipboard(text).then((ok) => {
if (ok) {
controller.log("Log entry copied to clipboard.", "clipboard");
} else {
controller.warn(
"Clipboard copy failed (no pbcopy/xclip/wl-copy/clip available).",
"clipboard",
);
}
});
}
return;
}
}
});

View File

@@ -342,11 +342,24 @@ export class DashboardTUI {
// If the cursor was sitting on the most recent entry (or there were no
// entries yet), keep it pinned to the new tail so live logs follow the
// latest event — same behavior as `tail -f` or k9s.
const beforeCount = this.getFilteredLogEntries().length;
const beforeEntries = this.getFilteredLogEntries();
const beforeCount = beforeEntries.length;
const wasAtTail = beforeCount === 0 || this.selectedLogIndex === beforeCount - 1;
// While the user is reading a single entry in expanded mode, pin the
// cursor on that entry so streaming logs don't yank the view away.
// Track by reference so ring-buffer eviction shifts the index correctly.
const pinnedEntry = this.logsExpandedMode ? beforeEntries[this.selectedLogIndex] : undefined;
this.logBuffer.push({ ...entry, timestamp: new Date() });
const after = this.getFilteredLogEntries();
if (wasAtTail) {
if (pinnedEntry) {
const newIdx = after.indexOf(pinnedEntry);
if (newIdx >= 0) {
this.selectedLogIndex = newIdx;
} else {
// Pinned entry was evicted from the ring buffer — fall back to oldest.
this.selectedLogIndex = 0;
}
} else if (wasAtTail) {
this.selectedLogIndex = Math.max(0, after.length - 1);
} else {
this.clampSelectedLogIndex(after);

View File

@@ -1,3 +1,36 @@
import { spawn } from "node:child_process";
export function isTTYAvailable(): boolean {
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
}
// Cross-platform clipboard write. Tries the native helper for the current
// platform; resolves false if no helper is available or the spawn fails so
// callers can surface a sensible error to the user.
export async function copyToClipboard(text: string): Promise<boolean> {
const candidates: Array<{ cmd: string; args: string[] }> =
process.platform === "darwin"
? [{ cmd: "pbcopy", args: [] }]
: process.platform === "win32"
? [{ cmd: "clip", args: [] }]
: [
{ cmd: "wl-copy", args: [] },
{ cmd: "xclip", args: ["-selection", "clipboard"] },
{ cmd: "xsel", args: ["--clipboard", "--input"] },
];
for (const { cmd, args } of candidates) {
const ok = await new Promise<boolean>((resolve) => {
try {
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
child.once("error", () => resolve(false));
child.once("close", (code) => resolve(code === 0));
child.stdin.end(text);
} catch {
resolve(false);
}
});
if (ok) return true;
}
return false;
}

View File

@@ -416,6 +416,22 @@
gap: var(--space-sm);
}
.settings-inline-link {
color: var(--todo);
text-decoration: none;
transition: text-decoration var(--transition-fast);
}
.settings-inline-link:hover {
text-decoration: underline;
}
.settings-inline-link:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
border-radius: var(--radius-sm);
}
/* === Memory Settings === */
.memory-status-message {
display: flex;

View File

@@ -3181,7 +3181,12 @@ export function SettingsModal({
/>
<small>
Your ntfy.sh topic name (164 alphanumeric/hyphen/underscore characters).{" "}
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="settings-inline-link"
>
Learn more about ntfy.sh
</a>
</small>

View File

@@ -151,11 +151,9 @@ function getDoneWorkflowRuntimeMs(task: Task): number | null {
function formatElapsedDuration(elapsedMs: number): string {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
if (elapsedMs < 1000) return `${Math.round(elapsedMs)}ms`;
if (elapsedMs < 60_000) return "<1m";
const elapsedSeconds = elapsedMs / 1000;
if (elapsedSeconds < 60) return `${elapsedSeconds.toFixed(1)}s`;
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
if (elapsedMinutes < 60) {
const remSeconds = Math.round(elapsedSeconds % 60);