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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user