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
17 changes: 17 additions & 0 deletions .agents/skills/pi-processes-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ When a feature needs a human in the loop (visual layout, keybinding feel, widget
- `s` cycles sort (status, started, name)
- `f` cycles filter (all, running, finished)
- `/` opens a name quick filter; `enter` applies, `esc` clears
- `?` opens the keybinds overlay (`?`, `esc`, `enter`, `q`, `ctrl+c` close it)
- `q` or `esc` closes

### /ps:logs overlay
Expand All @@ -73,11 +74,27 @@ When a feature needs a human in the loop (visual layout, keybinding feel, widget
- `tab` / `shift+tab` switch process tabs (viewer state is cached per process)
- `g/G` jump to top or bottom
- `j/k` or arrow keys scroll
- `pgup/pgdn` scroll by a full viewport
- `ctrl+u`/`ctrl+d` scroll by half a viewport
- `s` switches between combined, stdout, and stderr
- `f` toggles follow mode
- `w` toggles soft wrap
- `/` enters search, `n/N` cycles matches, `esc` clears search
- `?` opens the keybinds overlay (`?`, `esc`, `enter`, `q`, `ctrl+c` close it)
- `q` or `esc` closes

### Footer hints and the keybinds overlay

- Single-letter shortcuts whose key letter occurs in their word render as the
word with the key letter highlighted (accent + bold): `wrap`, `follow`,
`clear`, `sort`, `filter`, `kill`.
- Hints whose key is not in the word keep the classic `<key> <word>` display
(`q close`, `/ search`, `j/k scroll`).
- When the hint list does not fit the footer width, a leading `? more`
affordance appears and remaining hints are dropped from the right.
- `?` opens a stacked keybinds panel (herdr-style groups: scrolling, view,
tabs, general — plus a search group while a search is active).

### Dock and pin

- `/ps:dock expand|collapse|close` controls dock visibility
Expand Down
13 changes: 13 additions & 0 deletions .changeset/logs-paging-and-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@aliou/pi-processes": minor
---

The `/ps:logs` overlay now supports page scrolling: `PageUp`/`PageDown`
move by a full viewport and `ctrl+u`/`ctrl+d` by half a viewport.

Footers (`/ps` overview and `/ps:logs` overlay) now render compact
shortcut hints: when a hint's key letter appears in its word, only the
word is shown with the key highlighted in accent + bold ("w wrap" becomes
"wrap"). When the hint list does not fit the footer width, a leading
"? more" affordance appears; pressing `?` opens a stacked shortcuts
overlay listing every available key.
139 changes: 139 additions & 0 deletions extensions/processes-logs/components/log-overlay-component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const theme = {
} as unknown as Theme;

const TAB = "\t";
const ESC = String.fromCharCode(27);

function makeProcess(overrides: Partial<ProcessInfo> = {}): ProcessInfo {
return {
Expand Down Expand Up @@ -198,3 +199,141 @@ describe("LogOverlayComponent wrap toggle", () => {
expect(footer(overlay, width)).toContain("wrap");
});
});

// --- page scrolling ---

const PAGE_DOWN = `${ESC}[6~`;
const PAGE_UP = `${ESC}[5~`;
const CTRL_D = String.fromCharCode(4);
const CTRL_U = String.fromCharCode(21);

/** Log lines in the paging fixtures. */
const TOTAL_LINES = 50;
/** Terminal rows in the paging fixtures. */
const TERMINAL_ROWS = 40;
/** Viewport height (logRows()) for TERMINAL_ROWS under makeConfig(). */
const VIEWPORT_ROWS = 24;
/** Half-viewport rows scrolled by ctrl+u / ctrl+d. */
const HALF_PAGE_ROWS = VIEWPORT_ROWS / 2;

/** Overlay over a single process with `count` log lines. */
function makePagedOverlay(count: number, tuiOverrides: object = {}) {
const proc = makeProcess();
const initialLines: Record<string, ProcessLogLine[]> = {
proc_1: Array.from({ length: count }, (_, i) => ({
type: "stdout" as const,
text: `line ${i}`,
})),
};
const events = makeEvents([proc], initialLines);
const tui = {
requestRender: () => {},
terminal: { rows: TERMINAL_ROWS, columns: 120 },
...tuiOverrides,
} as unknown as TUI;
const overlay = new LogOverlayComponent({
events: events as never,
tui: tui as never,
theme: theme as never,
config: makeConfig(),
onClose: () => {},
initialProcessId: "proc_1",
});
return { overlay };
}

describe("LogOverlayComponent page scrolling", () => {
it("pageDown leaves follow mode and moves the viewport", () => {
const { overlay } = makePagedOverlay(TOTAL_LINES);
expect(footer(overlay, 120)).toContain("following");

overlay.handleInput(PAGE_DOWN);
const after = footer(overlay, 120);
expect(after).not.toContain("following");
expect(after).toContain(`L${TOTAL_LINES}/${TOTAL_LINES}`);
});

it("pageUp scrolls up by a full viewport", () => {
const { overlay } = makePagedOverlay(TOTAL_LINES);
overlay.handleInput(PAGE_DOWN); // stop following

overlay.handleInput(PAGE_UP);
expect(footer(overlay, 120)).toContain(
`L${TOTAL_LINES - VIEWPORT_ROWS}/${TOTAL_LINES}`,
);
});

it("ctrl+d / ctrl+u scroll by half a viewport", () => {
const { overlay } = makePagedOverlay(TOTAL_LINES);
overlay.handleInput(PAGE_DOWN); // stop following

overlay.handleInput(CTRL_U);
expect(footer(overlay, 120)).toContain(
`L${TOTAL_LINES - HALF_PAGE_ROWS}/${TOTAL_LINES}`,
);

overlay.handleInput(CTRL_D);
expect(footer(overlay, 120)).toContain(`L${TOTAL_LINES}/${TOTAL_LINES}`);
});

it("pageUp clamps so the viewport never ends above its own height", () => {
const { overlay } = makePagedOverlay(TOTAL_LINES);
overlay.handleInput(PAGE_DOWN);
overlay.handleInput(PAGE_UP);
overlay.handleInput(PAGE_UP);
expect(footer(overlay, 120)).toContain(`L${VIEWPORT_ROWS}/${TOTAL_LINES}`);
});
});

// --- "?" shortcuts overlay ---

/** Width wide enough for the stacked keybinds panel to render untruncated. */
const KEYBINDS_PANEL_WIDTH = 48;

describe("LogOverlayComponent shortcuts overlay", () => {
function makeOverlayWithTui() {
const hide = vi.fn();
const shown: { component: unknown; options: unknown }[] = [];
const tui = {
requestRender: () => {},
terminal: { rows: 40, columns: 120 },
showOverlay: vi.fn((component: unknown, options: unknown) => {
shown.push({ component, options });
return { hide };
}),
};
const { overlay } = makePagedOverlay(5, { showOverlay: tui.showOverlay });
return { overlay, hide, shown };
}

it("opens a stacked overlay with ? and closes it via the overlay's esc", () => {
const { overlay, hide, shown } = makeOverlayWithTui();

overlay.handleInput("?");
expect(shown.length).toBe(1);

const help = shown[0]?.component as {
handleInput: (data: string) => void;
render: (width: number) => string[];
};
const lines = help.render(KEYBINDS_PANEL_WIDTH).join("\n");
expect(lines).toContain("keybinds");
expect(lines).toContain("esc close");
help.handleInput(`${ESC}`);
expect(hide).toHaveBeenCalledTimes(1);
});

it("hides the shortcuts overlay when the log overlay closes", () => {
const { overlay, hide } = makeOverlayWithTui();
overlay.handleInput("?");
overlay.handleInput("q");
expect(hide).toHaveBeenCalledTimes(1);
});

it("does not open a second shortcuts overlay while one is open", () => {
const { overlay, shown } = makeOverlayWithTui();
overlay.handleInput("?");
overlay.handleInput("?");
expect(shown.length).toBe(1);
});
});
Loading