Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# dmux-style cockpit — Phase 4: logs viewer

## Why

Phase 1 wired the `[l]ogs` hotkey, phases 2-3 advertised it on the
welcome screen and shipped a similar overlay shape for projects. Phase
4 turns the placeholder logs panel into a real log viewer with the
same `[1] All [2] Info [3] Warnings [4] Errors [5] By Pane` filter row
the dmux UI uses.

## What changes

- New `src/cockpit/logs-reader.js`:
- `readLogs({ repoRoot, fs, sources, limit, tailBytes })` — walks
`apps/logs`, `.omc/logs`, `.omx/logs` (override via `sources`),
tails each `.log` file (default 32 KiB), splits into lines,
classifies each line with `classifyLevel`, returns
`{ entries, sources, counts }`.
- `classifyLevel(line)` — heuristic matcher for `error`, `warning`,
`debug`, default `info`.
- `filterEntries(entries, filter)` — slices by level or groups by
source for `by-pane`.
- `tallyLevels(entries)` — count summary.
- Real `renderLogsPanel`:
- Heading, summary row (`N total · N info · N warn · N err`),
`filter:` line, `sources:` count, the dmux filter row, then up to
20 most-recent entries tagged `[INF]`/`[WRN]`/`[ERR]`/`[DBG]` with
source path and message.
- Footer hints: `r: rescan Esc: back to main`.
- Control state hooks:
- Pressing `l` populates `state.logs` / `state.logsCounts` /
`state.logsSources` / `state.logsFilter` lazily on first entry.
- `1` / `2` / `3` / `4` / `5` swap the active filter.
- `r` rescans (re-reads log tails).
- `Esc` returns to main (existing behavior).

## Impact

- New module is filesystem-injectable for unit tests (no real disk
I/O required in CI).
- ASCII-only renderer; no unicode glyphs.
- No safety-model change: branches, worktrees, locks, PR-only finish
flow are untouched.

## Out of scope (later phases)

- Phase 5: New-agent prompt overlay.
- Phase 6: Terminal pane action wiring.
- Future: live tail (currently re-reads on `r`), scroll buffer beyond
the last 20 entries, color-coded levels, copy-to-clipboard.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## ADDED Requirements

### Requirement: Cockpit ships a logs reader module
The cockpit SHALL expose a `logs-reader` module that tails `.log`
files under configurable directories, classifies each line by level,
and returns a stable result with per-level counts.

#### Scenario: readLogs tails .log files and tallies levels
- **WHEN** `readLogs({ repoRoot, fs })` is called against a tree
containing `apps/logs/server.log` with mixed-severity lines and a
sibling `README.md`
- **THEN** the returned `entries` exclude the README and include one
classified entry per non-empty log line
- **AND** the returned `counts` accurately reflect the number of
`info`, `warning`, `error`, and `debug` entries.

#### Scenario: classifyLevel maps common keywords
- **WHEN** `classifyLevel(line)` is called with lines containing
`error`, `Exception`, `warning`, or `debug`
- **THEN** the returned levels are `error`, `error`, `warning`, and
`debug` respectively
- **AND** any line without a matching keyword classifies as `info`.

#### Scenario: filterEntries supports level and by-pane grouping
- **WHEN** `filterEntries(entries, 'error')` is called against a
mixed list
- **THEN** only entries with `level === 'error'` are returned.
- **AND** `filterEntries(entries, 'by-pane')` returns the entries
grouped by `source`, preserving relative order within each group.

### Requirement: Logs panel renders the dmux filter row and tagged entries
The cockpit `logs` mode panel SHALL render a summary line with total
and per-level counts, the dmux-style `[1] All [2] Info [3] Warnings
[4] Errors [5] By Pane` filter row, the active filter label, the
source count, and up to 20 most-recent entries tagged with `[INF]`,
`[WRN]`, `[ERR]`, or `[DBG]`. The footer SHALL list `r: rescan` and
`Esc: back to main`.

#### Scenario: Logs panel shows summary, filter row, tagged entries
- **WHEN** the cockpit is in `logs` mode with a known
`state.logs`, `state.logsCounts`, `state.logsSources`, and
`state.logsFilter === 'all'`
- **THEN** the rendered panel contains the substring `[1] All [2]
Info [3] Warnings [4] Errors [5] By Pane`
- **AND** every line from `state.logs` that ends up in the rendered
output is prefixed with `[INF]`, `[WRN]`, `[ERR]`, or `[DBG]`
- **AND** the footer contains `r: rescan`.

### Requirement: Logs mode key handlers swap filters and rescan
The cockpit key handler SHALL respond to `1` / `2` / `3` / `4` / `5`
in `logs` mode by setting `state.logsFilter` to `all` / `info` /
`warning` / `error` / `by-pane` respectively. It SHALL respond to `r`
by re-reading the log sources and refreshing the cached entries.

#### Scenario: 1-5 keys swap the active filter
- **WHEN** the cockpit is in `logs` mode with `logsFilter === 'all'`
and the user presses `2`, then `3`, then `4`, then `5`, then `1`
- **THEN** `logsFilter` becomes `info`, then `warning`, then `error`,
then `by-pane`, then `all`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Tasks

## 1. Spec
- [x] 1.1 Capture proposal in `proposal.md`
- [x] 1.2 Capture spec delta in `specs/cockpit-logs/spec.md`

## 2. Tests
- [x] 2.1 Add `test/cockpit-logs.test.js` covering `classifyLevel`,
`readLogs`, `filterEntries`, `tallyLevels`, the 1-5 filter
hotkeys, and the rendered logs panel.
- [x] 2.2 Verify existing cockpit-projects, cockpit-control, and
cockpit-sidebar tests still pass.

## 3. Implementation
- [x] 3.1 Add `src/cockpit/logs-reader.js` with `readLogs`,
`classifyLevel`, `filterEntries`, `tallyLevels`, `tailFile`,
`listLogPaths`, and the `LEVELS` / `DEFAULT_*` constants.
- [x] 3.2 Replace placeholder `renderLogsPanel` in
`src/cockpit/control.js` with a real viewer (summary, filter
row, tagged entries, footer hints, empty state).
- [x] 3.3 Add `loadLogsState` helper and call it from
`openActionRow('logs')` so the entries are hydrated lazily.
- [x] 3.4 In `applyKey`, route `1`-`5` to filter swaps and `r` to
rescan when `mode === 'logs'`.

## 4. Cleanup
- [ ] 4.1 Commit changes on the agent branch.
- [ ] 4.2 Push branch and open a PR.
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
- [ ] 4.4 Record PR URL and `MERGED` evidence.
99 changes: 90 additions & 9 deletions src/cockpit/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { stripAnsi } = require('./theme');
const { renderWelcomePage } = require('./welcome');
const { runCockpitAction } = require('./action-runner');
const { findProjects } = require('./projects-finder');
const { readLogs, filterEntries, LEVELS: LOG_LEVELS } = require('./logs-reader');
const {
PANE_MENU_ITEMS,
applyPaneMenuKey,
Expand Down Expand Up @@ -348,7 +349,12 @@ function openActionRow(state, actionId) {
return normalizeControlState({ ...current, mode: 'shortcuts', lastIntent: null });
}
if (actionId === 'logs') {
return normalizeControlState({ ...current, mode: 'logs', lastIntent: null });
const withLogs = loadLogsState(current);
return normalizeControlState({
...withLogs,
mode: 'logs',
lastIntent: null,
});
}
if (actionId === 'projects') {
const withProjects = loadProjectsState(current);
Expand Down Expand Up @@ -532,6 +538,19 @@ function applyKey(state, rawKey) {
const refreshed = loadProjectsState(current, { refresh: true });
return normalizeControlState({ ...refreshed, lastIntent: null });
}
if (mode === 'logs') {
if (Object.prototype.hasOwnProperty.call(LOGS_FILTER_KEYS, key)) {
return normalizeControlState({
...current,
logsFilter: LOGS_FILTER_KEYS[key],
lastIntent: null,
});
}
if (key === 'r') {
const refreshed = loadLogsState(current, { refresh: true });
return normalizeControlState({ ...refreshed, lastIntent: null });
}
}

return current;
}
Expand Down Expand Up @@ -708,21 +727,83 @@ function renderTerminalPanel(state) {
].join('\n');
}

const LOGS_FILTER_KEYS = {
'1': 'all',
'2': 'info',
'3': 'warning',
'4': 'error',
'5': 'by-pane',
};

function loadLogsState(current, options = {}) {
if (current.logs && options.refresh !== true) {
return current;
}
const result = readLogs({
repoRoot: current.repoPath,
fs: options.fs,
sources: options.sources,
limit: options.limit,
tailBytes: options.tailBytes,
});
return {
...current,
logs: result.entries,
logsCounts: result.counts,
logsSources: result.sources,
logsFilter: current.logsFilter || 'all',
};
}

function logsFilterLabel(filter) {
switch (filter) {
case 'info': return 'Info';
case 'warning': return 'Warnings';
case 'error': return 'Errors';
case 'by-pane': return 'By Pane';
default: return 'All';
}
}

function renderLogsPanel(state) {
const current = normalizeControlState(state);
const sessions = current.sessions.length;
return [
const counts = current.logsCounts || { all: 0 };
const filter = current.logsFilter || 'all';
const entries = filterEntries(current.logs || [], filter);
const sources = Array.isArray(current.logsSources) ? current.logsSources : [];
const summary = `${counts.all || 0} total`
+ ` ${counts.info || 0} info`
+ ` ${counts.warning || 0} warn`
+ ` ${counts.error || 0} err`;

const lines = [
'gitguardex logs',
'',
`repo: ${current.repoPath || '-'}`,
`active lanes: ${sessions}`,
summary,
`filter: ${logsFilterLabel(filter)}`,
`sources: ${sources.length}`,
'',
'[1] All [2] Info [3] Warnings [4] Errors [5] By Pane',
'',
'Live tail of `apps/logs/*.log` and lane heartbeats lands here.',
'Esc: back to main',
'',
].join('\n');
];

if (entries.length === 0) {
lines.push(' no log entries (filter or no log files yet)');
} else {
const tail = entries.slice(-20);
for (const entry of tail) {
const tag = entry.level === 'error' ? '[ERR]'
: entry.level === 'warning' ? '[WRN]'
: entry.level === 'debug' ? '[DBG]'
: '[INF]';
lines.push(`${tag} ${entry.source} · ${entry.line}`);
}
}

lines.push('');
lines.push('r: rescan Esc: back to main');
lines.push('');
return lines.join('\n');
}

function loadProjectsState(current, options = {}) {
Expand Down
Loading
Loading