fix(cli): make logs panel cursor visible and auto-follow tail

The selection arrow on the dashboard TUI's logs panel was rendering for
the wrong row: the list was reversed (newest-first) but selectedLogIndex
is in chronological order, and the viewport was anchored to the buffer's
tail. On a long log buffer the cursor pointed at the oldest entry, which
was always offscreen.

Switch the display to chronological order (oldest top, newest bottom —
matches tail/less/k9s), compute the viewport from the cursor so the
highlighted row is always visible, and surface "↑ N more / ↓ N more"
hints when entries scroll out of view. addLog now keeps the cursor
pinned to the tail when the user hasn't navigated away, so live logs
follow the latest event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 19:24:25 -07:00
parent b890c57a29
commit 4d43bf8d6b
2 changed files with 37 additions and 9 deletions

View File

@@ -343,15 +343,31 @@ function LogsPanel({
? state.logEntries
: state.logEntries.filter((e) => e.level === logsSeverityFilter);
const visibleEntries = entries.slice(-availableRows);
const visibleStart = Math.max(0, entries.length - availableRows);
// Default cursor to the newest entry when the user hasn't navigated yet
// (selectedLogIndex of 0 on a long buffer would be offscreen at the top).
const cursor = entries.length === 0
? 0
: Math.min(Math.max(selectedLogIndex, 0), entries.length - 1);
// Slide the viewport so the cursor is always visible. Newest entries sit at
// the bottom; oldest at the top — matching `tail`/`less` and how every
// human reads a log file.
const rowBudget = Math.max(1, availableRows);
const visibleStart = Math.max(0, Math.min(
cursor - Math.floor(rowBudget / 2),
entries.length - rowBudget,
));
const visibleEnd = Math.min(entries.length, visibleStart + rowBudget);
const visibleEntries = entries.slice(visibleStart, visibleEnd);
const hiddenAbove = visibleStart;
const hiddenBelow = entries.length - visibleEnd;
return (
<Panel title={`Logs (${state.logEntries.length}/1000)`} isFocused={isFocused} flexGrow={1}>
{logsExpandedMode && entries[selectedLogIndex] ? (
{logsExpandedMode && entries[cursor] ? (
<ExpandedLog
entry={entries[selectedLogIndex]}
index={selectedLogIndex}
entry={entries[cursor]}
index={cursor}
total={entries.length}
/>
) : entries.length === 0 ? (
@@ -363,10 +379,12 @@ function LogsPanel({
<Box flexDirection="row" gap={1} marginBottom={0}>
<Text dimColor>[w] wrap {logsWrapEnabled ? "on" : "off"}</Text>
<Text dimColor>[f] {logsSeverityFilter}</Text>
{hiddenAbove > 0 && <Text dimColor> {hiddenAbove} more</Text>}
{hiddenBelow > 0 && <Text dimColor> {hiddenBelow} more</Text>}
</Box>
{[...visibleEntries].reverse().map((entry, displayIdx) => {
const absoluteIndex = visibleStart + visibleEntries.length - 1 - displayIdx;
const isSelected = absoluteIndex === selectedLogIndex;
{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.

View File

@@ -139,8 +139,18 @@ export class DashboardTUI {
}
addLog(entry: Omit<LogEntry, "timestamp">): void {
// 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 wasAtTail = beforeCount === 0 || this.selectedLogIndex === beforeCount - 1;
this.logBuffer.push({ ...entry, timestamp: new Date() });
this.clampSelectedLogIndex(this.getFilteredLogEntries());
const after = this.getFilteredLogEntries();
if (wasAtTail) {
this.selectedLogIndex = Math.max(0, after.length - 1);
} else {
this.clampSelectedLogIndex(after);
}
this.notify();
}