diff --git a/.pi/extensions/prod-logs/index.ts b/.pi/extensions/prod-logs/index.ts index a989b00a..577939ed 100644 --- a/.pi/extensions/prod-logs/index.ts +++ b/.pi/extensions/prod-logs/index.ts @@ -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((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( + (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"); }, diff --git a/backlog/tasks/ml-159 - select-and-copy-log-lines-from-log-browser.md b/backlog/completed/ml-159 - select-and-copy-log-lines-from-log-browser.md similarity index 84% rename from backlog/tasks/ml-159 - select-and-copy-log-lines-from-log-browser.md rename to backlog/completed/ml-159 - select-and-copy-log-lines-from-log-browser.md index 38ced1f5..29be8f2a 100644 --- a/backlog/tasks/ml-159 - select-and-copy-log-lines-from-log-browser.md +++ b/backlog/completed/ml-159 - select-and-copy-log-lines-from-log-browser.md @@ -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`. To return copied text, the ## Acceptance Criteria -- [ ] #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) ## 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 | + +## Final Summary + + +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` changed to `ctx.ui.custom`; 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. +