ML-159: add cursor and visual mode to log browser
This commit is contained in:
@@ -26,10 +26,32 @@ interface CoolifyLogsResponse {
|
||||
logs?: string | string[];
|
||||
}
|
||||
|
||||
/** Minimal theme interface for color application in the LogViewer. */
|
||||
interface Theme {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
// ── Log Viewer Component ────────────────────────────────────────────────────
|
||||
|
||||
class LogViewer {
|
||||
lines: string[];
|
||||
|
||||
/** Absolute index into `this.lines` of the highlighted cursor line.
|
||||
* `-1` when there are no lines. */
|
||||
public cursorIndex: number = 0;
|
||||
|
||||
/** Whether visual (range-selection) mode is active. */
|
||||
public visualMode: boolean = false;
|
||||
|
||||
/** The line index where visual mode was entered (anchor of the selection
|
||||
* range). Only meaningful when `visualMode` is true. */
|
||||
public visualAnchor: number = 0;
|
||||
|
||||
/** Callback invoked when the user copies text (Enter in normal mode, y in
|
||||
* visual mode). The resolved promise value from `ctx.ui.custom` determines
|
||||
* whether the editor text is set. */
|
||||
public onCopy?: (text: string) => void;
|
||||
|
||||
private scrollOffset: number = 0;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
@@ -41,13 +63,19 @@ class LogViewer {
|
||||
constructor(lines: string[], visibleHeight: number) {
|
||||
this.lines = lines;
|
||||
this.visibleHeight = Math.max(1, visibleHeight);
|
||||
this.clampCursor();
|
||||
}
|
||||
|
||||
/** Replace lines and reset scroll to top. */
|
||||
/** Replace lines and reset all navigation state. */
|
||||
updateLines(lines: string[], chromeHeight: number): void {
|
||||
this.lines = lines;
|
||||
this.cursorIndex = 0;
|
||||
this.visualMode = false;
|
||||
this.visualAnchor = 0;
|
||||
this.scrollOffset = 0;
|
||||
this.visibleHeight = Math.min(lines.length, Math.max(10, process.stdout.rows - chromeHeight));
|
||||
this.clampCursor();
|
||||
this.clampViewport();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
@@ -59,45 +87,121 @@ class LogViewer {
|
||||
return this.lines.length;
|
||||
}
|
||||
|
||||
// ── Cursor / viewport helpers ──────────────────────────────────────────
|
||||
|
||||
/** Clamp cursorIndex to a valid range. Sets to -1 when there are no lines. */
|
||||
private clampCursor(): void {
|
||||
if (this.lines.length === 0) {
|
||||
this.cursorIndex = -1;
|
||||
} else {
|
||||
this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, this.lines.length - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/** Adjust scrollOffset so cursorIndex stays within the visible viewport. */
|
||||
private clampViewport(): void {
|
||||
if (this.lines.length === 0) return;
|
||||
if (this.cursorIndex < this.scrollOffset) {
|
||||
this.scrollOffset = this.cursorIndex;
|
||||
} else if (this.cursorIndex >= this.scrollOffset + this.visibleHeight) {
|
||||
this.scrollOffset = this.cursorIndex - this.visibleHeight + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input handling ─────────────────────────────────────────────────────
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
||||
// ── Mode-specific top-level keys ──────────────────────────────────
|
||||
|
||||
// Escape in visual mode exits visual mode (clears selection)
|
||||
if (this.visualMode && matchesKey(data, Key.escape)) {
|
||||
this.visualMode = false;
|
||||
this.visualAnchor = 0;
|
||||
this.invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape or q in normal mode closes the viewer
|
||||
if (!this.visualMode && (matchesKey(data, Key.escape) || matchesKey(data, "q"))) {
|
||||
this.onClose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh works in both modes
|
||||
if (matchesKey(data, "r")) {
|
||||
this.onRefresh?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Copy operations ───────────────────────────────────────────────
|
||||
|
||||
// Enter in either mode copies the cursor line
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
if (this.lines.length > 0 && this.cursorIndex >= 0) {
|
||||
this.onCopy?.(this.lines[this.cursorIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// y in visual mode copies the selected range (oldest-first order)
|
||||
if (this.visualMode && matchesKey(data, "y")) {
|
||||
if (this.lines.length > 0) {
|
||||
const start = Math.min(this.visualAnchor, this.cursorIndex);
|
||||
const end = Math.max(this.visualAnchor, this.cursorIndex);
|
||||
// this.lines[0] = newest, so reverse the slice for oldest-first
|
||||
const text = this.lines.slice(start, end + 1).reverse().join("\n");
|
||||
this.onCopy?.(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Enter visual mode ─────────────────────────────────────────────
|
||||
|
||||
if (!this.visualMode && matchesKey(data, "v") && this.cursorIndex >= 0) {
|
||||
this.visualMode = true;
|
||||
this.visualAnchor = this.cursorIndex;
|
||||
this.invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Movement (cursorIndex moves; viewport auto-clamps) ────────────
|
||||
|
||||
let moved = false;
|
||||
|
||||
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
||||
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
||||
this.invalidate();
|
||||
this.cursorIndex--;
|
||||
moved = true;
|
||||
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
||||
const maxOffset = Math.max(0, this.lines.length - this.visibleHeight);
|
||||
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + 1);
|
||||
this.invalidate();
|
||||
this.cursorIndex++;
|
||||
moved = true;
|
||||
} else if (matchesKey(data, Key.pageUp)) {
|
||||
this.scrollOffset = Math.max(0, this.scrollOffset - this.visibleHeight);
|
||||
this.invalidate();
|
||||
this.cursorIndex -= this.visibleHeight;
|
||||
moved = true;
|
||||
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
||||
const maxOffset = Math.max(0, this.lines.length - this.visibleHeight);
|
||||
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + this.visibleHeight);
|
||||
this.invalidate();
|
||||
this.cursorIndex += this.visibleHeight;
|
||||
moved = true;
|
||||
} else if (matchesKey(data, Key.ctrl("b"))) {
|
||||
this.scrollOffset = Math.max(0, this.scrollOffset - this.visibleHeight);
|
||||
this.invalidate();
|
||||
this.cursorIndex -= this.visibleHeight;
|
||||
moved = true;
|
||||
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
|
||||
// "g" twice = go to top; single "g" handled as first press
|
||||
this.scrollOffset = 0;
|
||||
this.invalidate();
|
||||
// "g" once jumps to top (in vim, "gg" or a single "g" both work)
|
||||
this.cursorIndex = 0;
|
||||
moved = true;
|
||||
} else if (matchesKey(data, Key.end) || matchesKey(data, "G")) {
|
||||
this.scrollOffset = Math.max(0, this.lines.length - this.visibleHeight);
|
||||
this.cursorIndex = this.lines.length - 1;
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
this.clampCursor();
|
||||
this.clampViewport();
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
render(width: number, theme: Theme): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
@@ -108,17 +212,41 @@ class LogViewer {
|
||||
this.scrollOffset + this.visibleHeight,
|
||||
);
|
||||
|
||||
// Compute selection range (only when visual mode is active)
|
||||
const selStart = this.visualMode
|
||||
? Math.min(this.visualAnchor, this.cursorIndex)
|
||||
: -1;
|
||||
const selEnd = this.visualMode
|
||||
? Math.max(this.visualAnchor, this.cursorIndex)
|
||||
: -1;
|
||||
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < visibleLines.length; i++) {
|
||||
const lineNum = this.scrollOffset + i + 1;
|
||||
const absIdx = this.scrollOffset + i;
|
||||
const lineNum = absIdx + 1;
|
||||
const numStr = String(lineNum).padStart(padLen, " ");
|
||||
const prefix = ` ${numStr} │ `;
|
||||
const content = truncateToWidth(
|
||||
`${prefix}${visibleLines[i]}`,
|
||||
width,
|
||||
"",
|
||||
);
|
||||
result.push(content);
|
||||
|
||||
// Base prefix widened by 2 chars to accommodate cursor/selection markers
|
||||
const basePrefix = ` ${numStr} │ `;
|
||||
|
||||
const isCursor = absIdx === this.cursorIndex;
|
||||
const isSelected = this.visualMode
|
||||
&& absIdx >= selStart
|
||||
&& absIdx <= selEnd;
|
||||
|
||||
if (isCursor) {
|
||||
// Cursor takes priority over selection highlight
|
||||
const line = `${basePrefix}> ${visibleLines[i]}`;
|
||||
const truncated = truncateToWidth(line, width, "");
|
||||
result.push(theme.fg("accent", truncated));
|
||||
} else if (isSelected) {
|
||||
const line = `${basePrefix}● ${visibleLines[i]}`;
|
||||
const truncated = truncateToWidth(line, width, "");
|
||||
result.push(theme.fg("success", truncated));
|
||||
} else {
|
||||
const line = `${basePrefix} ${visibleLines[i]}`;
|
||||
result.push(truncateToWidth(line, width, ""));
|
||||
}
|
||||
}
|
||||
|
||||
// Pad to visibleHeight if fewer lines
|
||||
@@ -271,63 +399,80 @@ export default function prodLogsExtension(pi: ExtensionAPI) {
|
||||
});
|
||||
};
|
||||
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
viewer.onClose = () => done();
|
||||
viewer.onRefresh = () => doRefresh(tui);
|
||||
// ── Interactive log browser (returns copied text or null) ─────────
|
||||
|
||||
return {
|
||||
render(width: number) {
|
||||
// Update scroll info
|
||||
const pct = viewer.total > 0
|
||||
? Math.round((viewer.offset / viewer.total) * 100)
|
||||
: 0;
|
||||
const from = viewer.offset + 1;
|
||||
const to = Math.min(
|
||||
viewer.offset + visibleLogLines(),
|
||||
viewer.total,
|
||||
);
|
||||
// Build result manually
|
||||
const result: string[] = [];
|
||||
const border = theme.fg("accent", "─".repeat(width));
|
||||
result.push(border);
|
||||
result.push(truncateToWidth(theme.fg("accent", theme.bold(" Production Logs")), width, ""));
|
||||
const copiedText = await ctx.ui.custom<string | null>(
|
||||
(tui, theme, _kb, done) => {
|
||||
viewer.onClose = () => done(null);
|
||||
viewer.onRefresh = () => doRefresh(tui);
|
||||
viewer.onCopy = (text: string) => done(text);
|
||||
|
||||
const countText = loading
|
||||
? " Refreshing..."
|
||||
: ` ${logLinesMut.length} lines`;
|
||||
result.push(truncateToWidth(theme.fg("muted", countText), width, ""));
|
||||
result.push(truncateToWidth(theme.fg("dim", "─".repeat(40)), width, ""));
|
||||
return {
|
||||
render(width: number) {
|
||||
const pct = viewer.total > 0
|
||||
? Math.round((viewer.offset / viewer.total) * 100)
|
||||
: 0;
|
||||
const from = viewer.offset + 1;
|
||||
const to = Math.min(
|
||||
viewer.offset + visibleLogLines(),
|
||||
viewer.total,
|
||||
);
|
||||
|
||||
const infoText = ` Lines ${from}-${to} of ${logLinesMut.length} (${pct}%)`;
|
||||
result.push(truncateToWidth(theme.fg("dim", infoText), width, ""));
|
||||
const result: string[] = [];
|
||||
const border = theme.fg("accent", "─".repeat(width));
|
||||
result.push(border);
|
||||
result.push(truncateToWidth(
|
||||
theme.fg("accent", theme.bold(" Production Logs")),
|
||||
width,
|
||||
"",
|
||||
));
|
||||
|
||||
// Log lines
|
||||
const logRendered = viewer.render(width);
|
||||
result.push(...logRendered);
|
||||
const countText = loading
|
||||
? " Refreshing..."
|
||||
: ` ${logLinesMut.length} lines`;
|
||||
result.push(truncateToWidth(theme.fg("muted", countText), width, ""));
|
||||
result.push(truncateToWidth(theme.fg("dim", "─".repeat(40)), width, ""));
|
||||
|
||||
const helpLine = loading
|
||||
? " Refreshing..."
|
||||
: " ↑↓/jk scroll · PgUp/PgDn page · Home/End jump · r refresh · Esc close";
|
||||
result.push(truncateToWidth(
|
||||
theme.fg("dim", helpLine),
|
||||
width,
|
||||
"",
|
||||
));
|
||||
result.push(border);
|
||||
const infoText = ` Lines ${from}-${to} of ${logLinesMut.length} (${pct}%)`;
|
||||
result.push(truncateToWidth(theme.fg("dim", infoText), width, ""));
|
||||
|
||||
return result;
|
||||
},
|
||||
// Log lines
|
||||
const logRendered = viewer.render(width, theme);
|
||||
result.push(...logRendered);
|
||||
|
||||
invalidate() {
|
||||
viewer.invalidate();
|
||||
},
|
||||
// Mode-aware help text
|
||||
const helpLine = loading
|
||||
? " Refreshing..."
|
||||
: viewer.visualMode
|
||||
? " VISUAL: j/k extend · y copy · Esc cancel · Enter copy line"
|
||||
: " ↑↓/jk scroll · PgUp/PgDn page · Home/End jump · v visual · Enter copy line · r refresh · Esc close";
|
||||
result.push(truncateToWidth(
|
||||
theme.fg("dim", helpLine),
|
||||
width,
|
||||
"",
|
||||
));
|
||||
result.push(border);
|
||||
|
||||
handleInput(data: string) {
|
||||
viewer.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
invalidate() {
|
||||
viewer.invalidate();
|
||||
},
|
||||
|
||||
handleInput(data: string) {
|
||||
viewer.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── Place copied text in editor ────────────────────────────────────
|
||||
|
||||
if (copiedText !== null) {
|
||||
ctx.ui.setEditorText(copiedText);
|
||||
}
|
||||
|
||||
ctx.ui.notify("Log viewer closed", "info");
|
||||
},
|
||||
|
||||
+27
-12
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: ML-159
|
||||
title: select and copy log lines from log browser
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-05-03 21:05'
|
||||
updated_date: '2026-05-03 21:20'
|
||||
updated_date: '2026-05-03 21:34'
|
||||
labels:
|
||||
- enhancement
|
||||
- pi-extension
|
||||
@@ -31,16 +31,16 @@ The log browser currently uses `ctx.ui.custom<void>`. To return copied text, the
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Cursor line is visually highlighted with accent color and `> ` prefix
|
||||
- [ ] #2 `v` enters visual mode; highlighted range extends as cursor moves
|
||||
- [ ] #3 `Escape` exits visual mode and clears selection
|
||||
- [ ] #4 `Enter` in normal mode copies the cursor line to editor and closes log browser
|
||||
- [ ] #5 `y` in visual mode copies the selected range (oldest-first order) to editor and closes log browser
|
||||
- [ ] #6 Copied text appears in the editor (via setEditorText) after log browser closes
|
||||
- [ ] #7 Pressing Escape (without copying) closes the log browser without changing editor content
|
||||
- [ ] #8 All existing key bindings (scroll, page, refresh, jump) continue to work in both normal and visual modes
|
||||
- [ ] #9 Help text updates to show visual mode key bindings when visual mode is active
|
||||
- [ ] #10 Empty or single-line log responses handle gracefully (no crash, sensible behavior)
|
||||
- [x] #1 Cursor line is visually highlighted with accent color and `> ` prefix
|
||||
- [x] #2 `v` enters visual mode; highlighted range extends as cursor moves
|
||||
- [x] #3 `Escape` exits visual mode and clears selection
|
||||
- [x] #4 `Enter` in normal mode copies the cursor line to editor and closes log browser
|
||||
- [x] #5 `y` in visual mode copies the selected range (oldest-first order) to editor and closes log browser
|
||||
- [x] #6 Copied text appears in the editor (via setEditorText) after log browser closes
|
||||
- [x] #7 Pressing Escape (without copying) closes the log browser without changing editor content
|
||||
- [x] #8 All existing key bindings (scroll, page, refresh, jump) continue to work in both normal and visual modes
|
||||
- [x] #9 Help text updates to show visual mode key bindings when visual mode is active
|
||||
- [x] #10 Empty or single-line log responses handle gracefully (no crash, sensible behavior)
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -182,3 +182,18 @@ None. Old behavior (scroll + refresh + close) is preserved and extended, not rep
|
||||
| `docs/available-tasks.md` | **No change** — no new mise tasks |
|
||||
| `.pi/extensions/prod-logs/index.ts` | **Inline comments** — add JSDoc on new fields (`cursorIndex`, `visualMode`, `visualAnchor`, `onCopy`) and new key binding branches |
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added vim-style cursor navigation and visual mode to the prod-logs pi extension:
|
||||
|
||||
- **Cursor**: `cursorIndex` tracks the highlighted line; all movement keys (j/k/PgUp/PgDn/Home/End/g/G) move the cursor with viewport auto-clamping via `clampViewport()`.
|
||||
- **Visual mode**: `v` enters range selection; movement keys extend the highlighted range (success color, `● ` prefix). Escape exits visual mode.
|
||||
- **Copy**: Enter copies the cursor line to the editor. `y` in visual mode copies the selected range in oldest-first order.
|
||||
- **Return type**: `ctx.ui.custom<void>` changed to `ctx.ui.custom<string | null>`; copied text placed via `ctx.ui.setEditorText()`.
|
||||
- **Edge cases**: Empty logs set cursorIndex to -1; visual mode guarded against empty state; `updateLines()` resets all navigation state on refresh.
|
||||
- **Help text**: Mode-aware — normal mode shows full key bindings, visual mode shows selection-specific keys.
|
||||
|
||||
Single-file change to `.pi/extensions/prod-logs/index.ts`. No Elixir code, database, or infrastructure changes. All 890 tests pass.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
Reference in New Issue
Block a user