feat(FN-2626): make task detail logs responsive to terminal width
- Add narrow-log helper formatting to compact task detail log output on small terminals - Implement responsive log panel layout that adapts between narrow and wide views - Preserve readable wide-terminal formatting while reducing wrapping in constrained widths - Add app-level tests covering narrow and wide log formatting behavior
This commit is contained in:
@@ -145,6 +145,11 @@ function makeInteractiveData(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
function setTerminalSize(instance: { stdout: unknown }, columns: number, rows: number) {
|
||||
Object.defineProperty(instance.stdout as object, "columns", { value: columns, configurable: true });
|
||||
Object.defineProperty(instance.stdout as object, "rows", { value: rows, configurable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -604,3 +609,75 @@ describe("LogsPanel indicator", () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogsPanel narrow formatting", () => {
|
||||
it("shows a compact index instead of full timestamp in narrow terminals", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
controller.setActiveSection("logs");
|
||||
controller.log("narrow entry", "scope");
|
||||
controller.setSelectedLogIndex(0);
|
||||
|
||||
const rendered = render(renderDashboardAppNode(controller));
|
||||
setTerminalSize(rendered, 60, 24);
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const frame = rendered.lastFrame() ?? "";
|
||||
|
||||
expect(frame).toContain("narrow entry");
|
||||
expect(frame).toMatch(/\s1\s[✓⚠✗]/);
|
||||
expect(frame).not.toMatch(/\d{2}:\d{2}:\d{2}\.\d{3}/);
|
||||
rendered.unmount();
|
||||
});
|
||||
|
||||
it("truncates long prefixes in narrow mode and keeps wide mode formatting", async () => {
|
||||
const prefix = "very-long-scope-name";
|
||||
|
||||
const narrowController = newController();
|
||||
narrowController.setSystemInfo(makeSystemInfo());
|
||||
narrowController.setActiveSection("logs");
|
||||
narrowController.log("narrow prefix", prefix);
|
||||
narrowController.setSelectedLogIndex(0);
|
||||
|
||||
const narrowRender = render(renderDashboardAppNode(narrowController));
|
||||
setTerminalSize(narrowRender, 60, 24);
|
||||
narrowRender.rerender(renderDashboardAppNode(narrowController));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const narrowFrame = narrowRender.lastFrame() ?? "";
|
||||
expect(narrowFrame).toContain("[very-…]");
|
||||
narrowRender.unmount();
|
||||
|
||||
const wideController = newController();
|
||||
wideController.setSystemInfo(makeSystemInfo());
|
||||
wideController.setActiveSection("logs");
|
||||
wideController.log("wide prefix", prefix);
|
||||
wideController.setSelectedLogIndex(0);
|
||||
|
||||
const wideRender = render(renderDashboardAppNode(wideController));
|
||||
setTerminalSize(wideRender, 120, 24);
|
||||
wideRender.rerender(renderDashboardAppNode(wideController));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const wideFrame = wideRender.lastFrame() ?? "";
|
||||
expect(wideFrame).toContain("[very-long-sco");
|
||||
expect(wideFrame).not.toContain("[very-…]");
|
||||
wideRender.unmount();
|
||||
});
|
||||
|
||||
it("preserves full timestamp formatting in wide terminals", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
controller.setActiveSection("logs");
|
||||
controller.log("wide timestamp", "scope");
|
||||
controller.setSelectedLogIndex(0);
|
||||
|
||||
const rendered = render(renderDashboardAppNode(controller));
|
||||
setTerminalSize(rendered, 120, 24);
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const frame = rendered.lastFrame() ?? "";
|
||||
|
||||
expect(frame).toContain("wide timestamp");
|
||||
expect(frame).toMatch(/\d{2}:\d{2}:\d{2}\.\d{3}/);
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -479,6 +479,21 @@ function SettingsPanel({ state, isFocused }: { state: DashboardState; isFocused:
|
||||
// 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;
|
||||
// Narrow-mode log layout: reduced widths to maximise message space.
|
||||
const NARROW_PREFIX_WIDTH = 8;
|
||||
|
||||
function narrowTimestamp(index: number): string {
|
||||
// Show a short 1-based index padded to 3 chars (e.g. " 1", " 42", "999").
|
||||
return String(index + 1).padStart(3);
|
||||
}
|
||||
|
||||
function narrowPrefix(prefix: string | undefined, maxWidth: number): string {
|
||||
if (!prefix) return " ".repeat(maxWidth);
|
||||
const bracketed = `[${prefix}]`;
|
||||
if (bracketed.length <= maxWidth) return bracketed.padEnd(maxWidth);
|
||||
// Truncate inside the brackets: "[long…]"
|
||||
return `[${prefix.slice(0, maxWidth - 3)}…]`.padEnd(maxWidth);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogEntry["level"] }) {
|
||||
if (level === "error") return <Text color="red">✗</Text>;
|
||||
@@ -500,6 +515,8 @@ function LogsPanel({
|
||||
}) {
|
||||
const { logsSeverityFilter, logsWrapEnabled, logsExpandedMode, selectedLogIndex } = state;
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const isNarrow = cols < NARROW_THRESHOLD;
|
||||
// Subtract the chrome (header ~2, status bar 1, panel borders/title ~3,
|
||||
// utilities/settings sub-row ~5) from live rows. Same heuristic the parent
|
||||
// grid used; keeping it co-located ensures we always read the freshest
|
||||
@@ -554,16 +571,35 @@ function LogsPanel({
|
||||
const isSelected = absoluteIndex === cursor;
|
||||
const bg = isSelected ? "cyan" : 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";
|
||||
const marker = isSelected ? "▶ " : " ";
|
||||
|
||||
if (isNarrow) {
|
||||
const idx = narrowTimestamp(absoluteIndex);
|
||||
const pfx = narrowPrefix(entry.prefix, NARROW_PREFIX_WIDTH);
|
||||
return (
|
||||
<Text
|
||||
key={`${entry.timestamp.getTime()}-${displayIdx}`}
|
||||
backgroundColor={bg}
|
||||
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
|
||||
>
|
||||
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
|
||||
<Text color={fg} dimColor={!isSelected}>{idx} </Text>
|
||||
<Text color={lvlColor}>{lvl}</Text>
|
||||
<Text color={fg} dimColor={!isSelected}>{` ${pfx} `}</Text>
|
||||
<Text color={fg} bold={isSelected}>{entry.message}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const ts = formatTimestamp(entry.timestamp);
|
||||
// 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 (
|
||||
<Text
|
||||
key={`${entry.timestamp.getTime()}-${displayIdx}`}
|
||||
@@ -1135,6 +1171,8 @@ function TaskDetailScreen({
|
||||
interactiveData: DashboardState["interactiveData"];
|
||||
}) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const isNarrow = cols < NARROW_THRESHOLD;
|
||||
// How many lines the log pane can show — leave room for header + steps + dividers.
|
||||
const rows = stdout?.rows ?? 24;
|
||||
|
||||
@@ -1386,6 +1424,18 @@ function TaskDetailScreen({
|
||||
entry.level === "warn" ? "yellow" :
|
||||
entry.level === "error" ? "red" :
|
||||
entry.level === "debug" ? "gray" : "white";
|
||||
|
||||
if (isNarrow) {
|
||||
const levelSymbol = entry.level === "error" ? "✗" : entry.level === "warn" ? "⚠" : "✓";
|
||||
return (
|
||||
<Box key={startIdx + i} flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Text dimColor>{narrowTimestamp(startIdx + i)}</Text>
|
||||
<Text color={levelColor}>{levelSymbol}</Text>
|
||||
<Text wrap="wrap">{entry.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const levelLabel = entry.level.toUpperCase().padEnd(5);
|
||||
return (
|
||||
<Box key={startIdx + i} flexDirection="row" gap={1} flexShrink={0}>
|
||||
|
||||
Reference in New Issue
Block a user