feat(coding-agent/debug): added mouse support to log and SSE viewers

- Implemented mouse wheel scrolling and click interaction for log entries.
- Added cursor navigation and expansion toggling via click in the log viewer.
- Updated help text to reflect available mouse controls.
This commit is contained in:
can1357
2026-06-28 08:20:28 +02:00
parent f1afe8faa4
commit 5fbe887697
4 changed files with 120 additions and 7 deletions
+76 -1
View File
@@ -3,6 +3,7 @@ import {
extractPrintableText,
matchesKey,
padding,
parseSgrMouse,
replaceTabs,
truncateToWidth,
visibleWidth,
@@ -187,6 +188,31 @@ export class DebugLogViewerModel {
}
}
moveCursorToRow(rowIndex: number, extendSelection: boolean): boolean {
const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex);
if (selectableIndex < 0) {
return false;
}
if (extendSelection && this.#selectionAnchorSelectableIndex === undefined) {
const row = this.#getCursorRow();
if (row?.kind === "log") {
this.#selectionAnchorSelectableIndex = this.#cursorSelectableIndex;
}
}
this.#cursorSelectableIndex = selectableIndex;
if (!extendSelection) {
this.#selectionAnchorSelectableIndex = undefined;
}
if (this.#getCursorRow()?.kind !== "log" && !extendSelection) {
this.#selectionAnchorSelectableIndex = undefined;
}
return true;
}
getSelectedLogIndices(): number[] {
if (this.#selectableRowIndices.length === 0) {
return [];
@@ -487,6 +513,9 @@ export class DebugLogViewerComponent implements Component {
#scrollRowOffset = 0;
#statusMessage: string | undefined;
#loadingOlder = false;
#bodyRowStart = 0;
#bodyRowCount = 0;
#bodyLineToRowIndex: Array<number | undefined> = [];
constructor(options: DebugLogViewerComponentOptions) {
this.#logSource = options.logSource;
@@ -504,6 +533,10 @@ export class DebugLogViewerComponent implements Component {
}
handleInput(keyData: string): void {
if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) {
return;
}
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
this.#onExit();
return;
@@ -601,6 +634,42 @@ export class DebugLogViewerComponent implements Component {
}
}
#handleMouse(keyData: string): boolean {
const event = parseSgrMouse(keyData);
if (!event) return false;
const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount;
if (event.wheel !== null && overBody) {
this.#statusMessage = undefined;
const maxOffset = Math.max(0, this.#model.rows.length - 1);
this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, this.#scrollRowOffset + event.wheel * 3));
this.#onUpdate?.();
return true;
}
if (!event.leftClick || !overBody) return false;
const rowIndex = this.#bodyLineToRowIndex[event.row - this.#bodyRowStart];
if (rowIndex === undefined) return false;
const target = this.#model.rows[rowIndex];
if (!target || target.kind === "warning") return false;
this.#statusMessage = undefined;
this.#model.moveCursorToRow(rowIndex, false);
if (target.kind === "load-older") {
void this.#handleLoadOlder();
return true;
}
if (this.#model.isExpanded(target.logIndex)) {
this.#model.collapseSelected();
} else {
this.#model.expandSelected();
}
this.#ensureCursorVisible();
this.#onUpdate?.();
return true;
}
invalidate(): void {
// no cached child state
}
@@ -613,6 +682,8 @@ export class DebugLogViewerComponent implements Component {
const bodyHeight = this.#bodyHeight();
const rows = this.#renderRows(contentWidth);
this.#bodyRowStart = 4;
this.#bodyRowCount = bodyHeight;
const visibleBodyLines = this.#renderVisibleBodyLines(rows, bodyHeight);
return [
@@ -635,7 +706,7 @@ export class DebugLogViewerComponent implements Component {
}
#controlsText(): string {
return "Esc close · Ctrl+C copy · ↑/↓ move · Shift+↑/↓ select · ←/→ collapse/expand · Ctrl+A all · Ctrl+O older · Ctrl+P pid";
return "Esc close · Ctrl+C copy · ↑/↓/wheel move · click toggle · Shift+↑/↓ select · ←/→ collapse/expand · Ctrl+A all · Ctrl+O older · Ctrl+P pid";
}
#filterText(): string {
@@ -786,8 +857,10 @@ export class DebugLogViewerComponent implements Component {
}
#renderVisibleBodyLines(rows: Array<{ lines: string[]; rowIndex: number }>, bodyHeight: number): string[] {
this.#bodyLineToRowIndex = [];
const lines: string[] = [];
if (rows.length === 0) {
this.#bodyLineToRowIndex.push(undefined);
lines.push(row(theme.fg("muted", "no matches"), this.#lastRenderWidth));
}
for (let i = this.#scrollRowOffset; i < rows.length; i++) {
@@ -800,6 +873,7 @@ export class DebugLogViewerComponent implements Component {
if (lines.length >= bodyHeight) {
break;
}
this.#bodyLineToRowIndex.push(renderedRow.rowIndex);
lines.push(row(line, this.#lastRenderWidth));
}
@@ -809,6 +883,7 @@ export class DebugLogViewerComponent implements Component {
}
while (lines.length < bodyHeight) {
this.#bodyLineToRowIndex.push(undefined);
lines.push(row("", this.#lastRenderWidth));
}
+39 -2
View File
@@ -1,4 +1,4 @@
import { type Component, matchesKey, replaceTabs, ScrollView, truncateToWidth } from "@oh-my-pi/pi-tui";
import { type Component, matchesKey, parseSgrMouse, replaceTabs, ScrollView, truncateToWidth } from "@oh-my-pi/pi-tui";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { bottomBorder, divider, row, topBorder } from "../modes/components/overlay-box";
import { theme } from "../modes/theme/theme";
@@ -72,6 +72,8 @@ export class RawSseViewerComponent implements Component {
#followTail = true;
#lastRenderWidth = MIN_VIEWER_WIDTH;
#statusMessage: string | undefined;
#bodyRowStart = 0;
#bodyRowCount = 0;
// Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
// the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
// the render path runs on every keypress and from `#maxScrollOffset()`.
@@ -96,6 +98,10 @@ export class RawSseViewerComponent implements Component {
}
handleInput(keyData: string): void {
if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) {
return;
}
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
this.dispose();
this.#onExit();
@@ -142,6 +148,35 @@ export class RawSseViewerComponent implements Component {
}
}
#handleMouse(keyData: string): boolean {
const event = parseSgrMouse(keyData);
if (!event) return false;
const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount;
if (event.wheel !== null && overBody) {
this.#followTail = false;
this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), this.#scrollOffset + event.wheel * 3));
this.#onUpdate?.();
return true;
}
if (!event.leftClick) return false;
if (event.row === 1) {
this.#followTail = !this.#followTail;
this.#followIfNeeded();
this.#onUpdate?.();
return true;
}
if (overBody) {
this.#followTail = false;
const clickedOffset = this.#scrollOffset + event.row - this.#bodyRowStart;
this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), clickedOffset));
this.#onUpdate?.();
return true;
}
return false;
}
invalidate(): void {}
render(width: number): readonly string[] {
@@ -159,6 +194,8 @@ export class RawSseViewerComponent implements Component {
});
sv.setScrollOffset(this.#scrollOffset);
const bodyRows = sv.render(contentWidth);
this.#bodyRowStart = 3;
this.#bodyRowCount = bodyHeight;
return [
topBorder(this.#lastRenderWidth, "Raw Provider Stream"),
@@ -229,7 +266,7 @@ export class RawSseViewerComponent implements Component {
}
#statusText(): string {
const help = "Esc close · Ctrl+C copy raw · End follow tail · ↑/↓ PgUp/PgDn scroll";
const help = "Esc close · Ctrl+C copy raw · End follow tail · wheel scroll · click summary toggles follow";
return this.#statusMessage
? `${theme.fg("success", this.#statusMessage)} ${theme.fg("dim", help)}`
: theme.fg("dim", help);