Skip to content

Commit 1623ca3

Browse files
NagyViktNagyViktclaude
authored
Add a real logs viewer overlay to the gx cockpit (#527)
Phase 4 of the dmux-style cockpit plan: replace the placeholder logs panel with a real viewer that tails .log files under apps/logs, .omc/logs, and .omx/logs, classifies each line by level, and renders a summary row, the dmux-style [1] All [2] Info [3] Warnings [4] Errors [5] By Pane filter row, and up to 20 most-recent entries tagged with [INF]/[WRN]/[ERR]/[DBG]. Number keys swap the active filter, r rescans, Esc returns to main. The reader is fs-injectable for unit tests. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4187fc2 commit 1623ca3

6 files changed

Lines changed: 571 additions & 9 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# dmux-style cockpit — Phase 4: logs viewer
2+
3+
## Why
4+
5+
Phase 1 wired the `[l]ogs` hotkey, phases 2-3 advertised it on the
6+
welcome screen and shipped a similar overlay shape for projects. Phase
7+
4 turns the placeholder logs panel into a real log viewer with the
8+
same `[1] All [2] Info [3] Warnings [4] Errors [5] By Pane` filter row
9+
the dmux UI uses.
10+
11+
## What changes
12+
13+
- New `src/cockpit/logs-reader.js`:
14+
- `readLogs({ repoRoot, fs, sources, limit, tailBytes })` — walks
15+
`apps/logs`, `.omc/logs`, `.omx/logs` (override via `sources`),
16+
tails each `.log` file (default 32 KiB), splits into lines,
17+
classifies each line with `classifyLevel`, returns
18+
`{ entries, sources, counts }`.
19+
- `classifyLevel(line)` — heuristic matcher for `error`, `warning`,
20+
`debug`, default `info`.
21+
- `filterEntries(entries, filter)` — slices by level or groups by
22+
source for `by-pane`.
23+
- `tallyLevels(entries)` — count summary.
24+
- Real `renderLogsPanel`:
25+
- Heading, summary row (`N total · N info · N warn · N err`),
26+
`filter:` line, `sources:` count, the dmux filter row, then up to
27+
20 most-recent entries tagged `[INF]`/`[WRN]`/`[ERR]`/`[DBG]` with
28+
source path and message.
29+
- Footer hints: `r: rescan Esc: back to main`.
30+
- Control state hooks:
31+
- Pressing `l` populates `state.logs` / `state.logsCounts` /
32+
`state.logsSources` / `state.logsFilter` lazily on first entry.
33+
- `1` / `2` / `3` / `4` / `5` swap the active filter.
34+
- `r` rescans (re-reads log tails).
35+
- `Esc` returns to main (existing behavior).
36+
37+
## Impact
38+
39+
- New module is filesystem-injectable for unit tests (no real disk
40+
I/O required in CI).
41+
- ASCII-only renderer; no unicode glyphs.
42+
- No safety-model change: branches, worktrees, locks, PR-only finish
43+
flow are untouched.
44+
45+
## Out of scope (later phases)
46+
47+
- Phase 5: New-agent prompt overlay.
48+
- Phase 6: Terminal pane action wiring.
49+
- Future: live tail (currently re-reads on `r`), scroll buffer beyond
50+
the last 20 entries, color-coded levels, copy-to-clipboard.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Cockpit ships a logs reader module
4+
The cockpit SHALL expose a `logs-reader` module that tails `.log`
5+
files under configurable directories, classifies each line by level,
6+
and returns a stable result with per-level counts.
7+
8+
#### Scenario: readLogs tails .log files and tallies levels
9+
- **WHEN** `readLogs({ repoRoot, fs })` is called against a tree
10+
containing `apps/logs/server.log` with mixed-severity lines and a
11+
sibling `README.md`
12+
- **THEN** the returned `entries` exclude the README and include one
13+
classified entry per non-empty log line
14+
- **AND** the returned `counts` accurately reflect the number of
15+
`info`, `warning`, `error`, and `debug` entries.
16+
17+
#### Scenario: classifyLevel maps common keywords
18+
- **WHEN** `classifyLevel(line)` is called with lines containing
19+
`error`, `Exception`, `warning`, or `debug`
20+
- **THEN** the returned levels are `error`, `error`, `warning`, and
21+
`debug` respectively
22+
- **AND** any line without a matching keyword classifies as `info`.
23+
24+
#### Scenario: filterEntries supports level and by-pane grouping
25+
- **WHEN** `filterEntries(entries, 'error')` is called against a
26+
mixed list
27+
- **THEN** only entries with `level === 'error'` are returned.
28+
- **AND** `filterEntries(entries, 'by-pane')` returns the entries
29+
grouped by `source`, preserving relative order within each group.
30+
31+
### Requirement: Logs panel renders the dmux filter row and tagged entries
32+
The cockpit `logs` mode panel SHALL render a summary line with total
33+
and per-level counts, the dmux-style `[1] All [2] Info [3] Warnings
34+
[4] Errors [5] By Pane` filter row, the active filter label, the
35+
source count, and up to 20 most-recent entries tagged with `[INF]`,
36+
`[WRN]`, `[ERR]`, or `[DBG]`. The footer SHALL list `r: rescan` and
37+
`Esc: back to main`.
38+
39+
#### Scenario: Logs panel shows summary, filter row, tagged entries
40+
- **WHEN** the cockpit is in `logs` mode with a known
41+
`state.logs`, `state.logsCounts`, `state.logsSources`, and
42+
`state.logsFilter === 'all'`
43+
- **THEN** the rendered panel contains the substring `[1] All [2]
44+
Info [3] Warnings [4] Errors [5] By Pane`
45+
- **AND** every line from `state.logs` that ends up in the rendered
46+
output is prefixed with `[INF]`, `[WRN]`, `[ERR]`, or `[DBG]`
47+
- **AND** the footer contains `r: rescan`.
48+
49+
### Requirement: Logs mode key handlers swap filters and rescan
50+
The cockpit key handler SHALL respond to `1` / `2` / `3` / `4` / `5`
51+
in `logs` mode by setting `state.logsFilter` to `all` / `info` /
52+
`warning` / `error` / `by-pane` respectively. It SHALL respond to `r`
53+
by re-reading the log sources and refreshing the cached entries.
54+
55+
#### Scenario: 1-5 keys swap the active filter
56+
- **WHEN** the cockpit is in `logs` mode with `logsFilter === 'all'`
57+
and the user presses `2`, then `3`, then `4`, then `5`, then `1`
58+
- **THEN** `logsFilter` becomes `info`, then `warning`, then `error`,
59+
then `by-pane`, then `all`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Tasks
2+
3+
## 1. Spec
4+
- [x] 1.1 Capture proposal in `proposal.md`
5+
- [x] 1.2 Capture spec delta in `specs/cockpit-logs/spec.md`
6+
7+
## 2. Tests
8+
- [x] 2.1 Add `test/cockpit-logs.test.js` covering `classifyLevel`,
9+
`readLogs`, `filterEntries`, `tallyLevels`, the 1-5 filter
10+
hotkeys, and the rendered logs panel.
11+
- [x] 2.2 Verify existing cockpit-projects, cockpit-control, and
12+
cockpit-sidebar tests still pass.
13+
14+
## 3. Implementation
15+
- [x] 3.1 Add `src/cockpit/logs-reader.js` with `readLogs`,
16+
`classifyLevel`, `filterEntries`, `tallyLevels`, `tailFile`,
17+
`listLogPaths`, and the `LEVELS` / `DEFAULT_*` constants.
18+
- [x] 3.2 Replace placeholder `renderLogsPanel` in
19+
`src/cockpit/control.js` with a real viewer (summary, filter
20+
row, tagged entries, footer hints, empty state).
21+
- [x] 3.3 Add `loadLogsState` helper and call it from
22+
`openActionRow('logs')` so the entries are hydrated lazily.
23+
- [x] 3.4 In `applyKey`, route `1`-`5` to filter swaps and `r` to
24+
rescan when `mode === 'logs'`.
25+
26+
## 4. Cleanup
27+
- [ ] 4.1 Commit changes on the agent branch.
28+
- [ ] 4.2 Push branch and open a PR.
29+
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
30+
- [ ] 4.4 Record PR URL and `MERGED` evidence.

src/cockpit/control.js

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { stripAnsi } = require('./theme');
88
const { renderWelcomePage } = require('./welcome');
99
const { runCockpitAction } = require('./action-runner');
1010
const { findProjects } = require('./projects-finder');
11+
const { readLogs, filterEntries, LEVELS: LOG_LEVELS } = require('./logs-reader');
1112
const {
1213
PANE_MENU_ITEMS,
1314
applyPaneMenuKey,
@@ -348,7 +349,12 @@ function openActionRow(state, actionId) {
348349
return normalizeControlState({ ...current, mode: 'shortcuts', lastIntent: null });
349350
}
350351
if (actionId === 'logs') {
351-
return normalizeControlState({ ...current, mode: 'logs', lastIntent: null });
352+
const withLogs = loadLogsState(current);
353+
return normalizeControlState({
354+
...withLogs,
355+
mode: 'logs',
356+
lastIntent: null,
357+
});
352358
}
353359
if (actionId === 'projects') {
354360
const withProjects = loadProjectsState(current);
@@ -532,6 +538,19 @@ function applyKey(state, rawKey) {
532538
const refreshed = loadProjectsState(current, { refresh: true });
533539
return normalizeControlState({ ...refreshed, lastIntent: null });
534540
}
541+
if (mode === 'logs') {
542+
if (Object.prototype.hasOwnProperty.call(LOGS_FILTER_KEYS, key)) {
543+
return normalizeControlState({
544+
...current,
545+
logsFilter: LOGS_FILTER_KEYS[key],
546+
lastIntent: null,
547+
});
548+
}
549+
if (key === 'r') {
550+
const refreshed = loadLogsState(current, { refresh: true });
551+
return normalizeControlState({ ...refreshed, lastIntent: null });
552+
}
553+
}
535554

536555
return current;
537556
}
@@ -708,21 +727,83 @@ function renderTerminalPanel(state) {
708727
].join('\n');
709728
}
710729

730+
const LOGS_FILTER_KEYS = {
731+
'1': 'all',
732+
'2': 'info',
733+
'3': 'warning',
734+
'4': 'error',
735+
'5': 'by-pane',
736+
};
737+
738+
function loadLogsState(current, options = {}) {
739+
if (current.logs && options.refresh !== true) {
740+
return current;
741+
}
742+
const result = readLogs({
743+
repoRoot: current.repoPath,
744+
fs: options.fs,
745+
sources: options.sources,
746+
limit: options.limit,
747+
tailBytes: options.tailBytes,
748+
});
749+
return {
750+
...current,
751+
logs: result.entries,
752+
logsCounts: result.counts,
753+
logsSources: result.sources,
754+
logsFilter: current.logsFilter || 'all',
755+
};
756+
}
757+
758+
function logsFilterLabel(filter) {
759+
switch (filter) {
760+
case 'info': return 'Info';
761+
case 'warning': return 'Warnings';
762+
case 'error': return 'Errors';
763+
case 'by-pane': return 'By Pane';
764+
default: return 'All';
765+
}
766+
}
767+
711768
function renderLogsPanel(state) {
712769
const current = normalizeControlState(state);
713-
const sessions = current.sessions.length;
714-
return [
770+
const counts = current.logsCounts || { all: 0 };
771+
const filter = current.logsFilter || 'all';
772+
const entries = filterEntries(current.logs || [], filter);
773+
const sources = Array.isArray(current.logsSources) ? current.logsSources : [];
774+
const summary = `${counts.all || 0} total`
775+
+ ` ${counts.info || 0} info`
776+
+ ` ${counts.warning || 0} warn`
777+
+ ` ${counts.error || 0} err`;
778+
779+
const lines = [
715780
'gitguardex logs',
716781
'',
717-
`repo: ${current.repoPath || '-'}`,
718-
`active lanes: ${sessions}`,
782+
summary,
783+
`filter: ${logsFilterLabel(filter)}`,
784+
`sources: ${sources.length}`,
719785
'',
720786
'[1] All [2] Info [3] Warnings [4] Errors [5] By Pane',
721787
'',
722-
'Live tail of `apps/logs/*.log` and lane heartbeats lands here.',
723-
'Esc: back to main',
724-
'',
725-
].join('\n');
788+
];
789+
790+
if (entries.length === 0) {
791+
lines.push(' no log entries (filter or no log files yet)');
792+
} else {
793+
const tail = entries.slice(-20);
794+
for (const entry of tail) {
795+
const tag = entry.level === 'error' ? '[ERR]'
796+
: entry.level === 'warning' ? '[WRN]'
797+
: entry.level === 'debug' ? '[DBG]'
798+
: '[INF]';
799+
lines.push(`${tag} ${entry.source} · ${entry.line}`);
800+
}
801+
}
802+
803+
lines.push('');
804+
lines.push('r: rescan Esc: back to main');
805+
lines.push('');
806+
return lines.join('\n');
726807
}
727808

728809
function loadProjectsState(current, options = {}) {

0 commit comments

Comments
 (0)