feat(cli): default focus to logs panel and tighten splash + log row layout
- Default activeSection is now "logs" so the live log feed is what the user sees first when running `fn`. - Splash drops the ASCII circle brand mark; only the FUSION block-letter word and tagline remain. The splash always pins to the top-left rather than centering, so it doesn't shift on resize. Compact terminals show plain "FUSION" text instead of block letters. - Log rows render as a single Text with inline-colored spans (marker, timestamp, level, prefix, message). Prefix sits in a fixed 14-char slot so the message column aligns across rows, and the selection highlight now spans the full row width. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,23 +60,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("DashboardApp smoke", () => {
|
||||
it("renders the splash brand mark and tagline before systemInfo arrives", () => {
|
||||
it("renders the splash logo and tagline before systemInfo arrives", () => {
|
||||
const controller = newController();
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("╭─────╮");
|
||||
// Block-letter "F" opens with this run on wide terminals; on narrow
|
||||
// terminals the compact layout shows plain "FUSION".
|
||||
expect(frame).toMatch(/███████╗|FUSION/);
|
||||
expect(frame).toContain("AI coding agent dashboard");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("reveals the FUSION block letters after the wipe-in animation runs", async () => {
|
||||
const controller = newController();
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
expect(lastFrame() ?? "").toContain("███████╗");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders system panel content once setSystemInfo fires", () => {
|
||||
const controller = newController();
|
||||
const { lastFrame, unmount, rerender } = render(<DashboardApp controller={controller} />);
|
||||
|
||||
@@ -57,27 +57,6 @@ function logoColor(index: number): InkColor {
|
||||
return LOGO_COLORS[Math.min(index, LOGO_COLORS.length - 1)];
|
||||
}
|
||||
|
||||
// ── Brand mark ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FUSION_MARK_LINES = [
|
||||
" ╭─────╮ ",
|
||||
" ╱ ╱╲ ",
|
||||
" │ ╱ │ ",
|
||||
" │ ╱ │ ",
|
||||
" ╲ ╱ ╱ ",
|
||||
" ╰───╯ ",
|
||||
];
|
||||
|
||||
function FusionMark() {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
{FUSION_MARK_LINES.map((line, i) => (
|
||||
<Text key={i} color="cyanBright" bold>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedFusionLogo() {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
@@ -91,8 +70,10 @@ function AnimatedFusionLogo() {
|
||||
// ── Splash screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Smallest terminal that can fit the full block-letter splash centered.
|
||||
// Below either threshold we drop into a compact top-left layout so nothing
|
||||
// gets clipped or re-centered awkwardly.
|
||||
// Below either threshold the block-letter logo gets dropped for a plain
|
||||
// "FUSION" word so nothing wraps awkwardly on small terminals. The splash
|
||||
// always pins to the top-left — never centered — so it doesn't jump around
|
||||
// when the terminal resizes.
|
||||
const SPLASH_MIN_COLS = 56;
|
||||
const SPLASH_MIN_ROWS = 20;
|
||||
|
||||
@@ -102,33 +83,13 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const compact = cols < SPLASH_MIN_COLS || rows < SPLASH_MIN_ROWS;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
<FusionMark />
|
||||
<Box height={1} />
|
||||
<Text bold color="cyanBright">FUSION</Text>
|
||||
<Text color="blueBright" dimColor>{FUSION_TAGLINE}</Text>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color="cyanBright"><Spinner type="dots" /></Text>
|
||||
<Text color="blueBright" dimColor>{loadingStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
<FusionMark />
|
||||
<Box height={1} />
|
||||
<AnimatedFusionLogo />
|
||||
<Box height={1} />
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{compact ? (
|
||||
<Text bold color="cyanBright">FUSION</Text>
|
||||
) : (
|
||||
<AnimatedFusionLogo />
|
||||
)}
|
||||
<Text color="blueBright" dimColor>{FUSION_TAGLINE}</Text>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
@@ -322,6 +283,10 @@ function SettingsPanel({ state, isFocused }: { state: DashboardState; isFocused:
|
||||
|
||||
// ── Logs panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Width of the prefix slot in log rows. Long prefixes are truncated; short
|
||||
// ones (and missing prefixes) are padded so the message column stays aligned.
|
||||
const PREFIX_WIDTH = 14;
|
||||
|
||||
function LevelBadge({ level }: { level: LogEntry["level"] }) {
|
||||
if (level === "error") return <Text color="red">✗</Text>;
|
||||
if (level === "warn") return <Text color="yellow">⚠</Text>;
|
||||
@@ -385,36 +350,30 @@ function LogsPanel({
|
||||
{visibleEntries.map((entry, displayIdx) => {
|
||||
const absoluteIndex = visibleStart + displayIdx;
|
||||
const isSelected = absoluteIndex === cursor;
|
||||
// Selected row: blue background + bright arrow + bold text. The
|
||||
// background spans the whole row so the cursor is visible at a
|
||||
// glance even on a busy logs panel.
|
||||
const bg = isSelected ? "blue" : undefined;
|
||||
const fg = isSelected ? "whiteBright" : undefined;
|
||||
const ts = formatTimestamp(entry.timestamp);
|
||||
const lvl = entry.level === "error" ? "✗" : entry.level === "warn" ? "⚠" : "✓";
|
||||
const lvlColor = entry.level === "error" ? "red" : entry.level === "warn" ? "yellow" : "green";
|
||||
// Pad/truncate prefix to a fixed slot so message column aligns
|
||||
// across rows (rows without a prefix get blank padding instead of
|
||||
// collapsing).
|
||||
const prefixSlot = entry.prefix
|
||||
? `[${entry.prefix}]`.slice(0, PREFIX_WIDTH).padEnd(PREFIX_WIDTH)
|
||||
: " ".repeat(PREFIX_WIDTH);
|
||||
const marker = isSelected ? "▶ " : " ";
|
||||
return (
|
||||
<Box key={`${entry.timestamp.getTime()}-${displayIdx}`} flexDirection="row" gap={1}>
|
||||
<Text backgroundColor={bg} color={isSelected ? "cyanBright" : "gray"} bold={isSelected}>
|
||||
{isSelected ? "▶" : " "}
|
||||
</Text>
|
||||
<Text backgroundColor={bg} color={fg} dimColor={!isSelected}>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</Text>
|
||||
<Text backgroundColor={bg}>
|
||||
<LevelBadge level={entry.level} />
|
||||
</Text>
|
||||
{entry.prefix && (
|
||||
<Text backgroundColor={bg} color={fg} dimColor={!isSelected}>
|
||||
[{entry.prefix}]
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
backgroundColor={bg}
|
||||
color={fg}
|
||||
bold={isSelected}
|
||||
wrap={logsWrapEnabled ? "wrap" : "truncate"}
|
||||
>
|
||||
{entry.message}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
key={`${entry.timestamp.getTime()}-${displayIdx}`}
|
||||
backgroundColor={bg}
|
||||
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
|
||||
>
|
||||
<Text color={isSelected ? "cyanBright" : "gray"} bold={isSelected}>{marker}</Text>
|
||||
<Text color={fg} dimColor={!isSelected}>{ts} </Text>
|
||||
<Text color={lvlColor}>{lvl}</Text>
|
||||
<Text color={fg} dimColor={!isSelected}>{` ${prefixSlot} `}</Text>
|
||||
<Text color={fg} bold={isSelected}>{entry.message}</Text>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { SECTION_ORDER } from "./state.js";
|
||||
|
||||
export class DashboardTUI {
|
||||
// State fields mirror the original private layout so tests can access them.
|
||||
activeSection: SectionId = "system";
|
||||
activeSection: SectionId = "logs";
|
||||
// Named `logBuffer` to match what captureConsole tests access via
|
||||
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
|
||||
logBuffer: LogRingBuffer;
|
||||
|
||||
@@ -144,7 +144,7 @@ export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats
|
||||
|
||||
export function createInitialState(): DashboardState {
|
||||
return {
|
||||
activeSection: "system",
|
||||
activeSection: "logs",
|
||||
logEntries: [],
|
||||
systemInfo: null,
|
||||
taskStats: null,
|
||||
|
||||
Reference in New Issue
Block a user