feat(debugger): search the debug console output (#1502)

The Debug console had no Find: Cmd-F did nothing there, in the Debug tool window and in Services' debug output alike. It now searches the whole buffer (Match Case / Words / Regex) with the n/m counter, highlights every match with the current one on its own background, scrolls it into view, and offers the Run console's context menu (Copy, Select All, Copy All, Find…, Clear All).

The find matching and find bar moved from the Run console into src/ui (consoleFind.ts, ConsoleFindBar.tsx) and are shared by both consoles; Enter on one of the bar's buttons activates it instead of jumping to the next match.

Co-authored-by: DomKing <7285294+DomKing@users.noreply.github.com>
This commit is contained in:
DomKing
2026-11-05 21:03:28 +08:00
committed by GitHub
co-authored by DomKing
parent 24cf1a52d5
commit 253fcfb7e0
9 changed files with 494 additions and 54 deletions
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ContextMenuHost } from "@/ui";
import { usePlatform } from "@/platform/platformInfo";
import { adoptSession, appendConsole, useDebug } from "../state";
import { DebugConsole } from "../ui/DebugConsole";
const clipboard = vi.hoisted(() => ({ writeText: vi.fn(async () => {}) }));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 18,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({ index, start: index * 18, size: 18, key: index })),
scrollToIndex: () => {},
measureElement: () => {},
measure: () => {},
}),
}));
beforeEach(() => {
usePlatform.setState({ os: "macos" });
useDebug.setState({ sessions: {}, console: {}, active: null });
clipboard.writeText.mockClear();
Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true });
window.getSelection()?.removeAllRanges();
});
function setup(text = "alpha beta\ngamma Beta\nbeta again\n") {
const session = adoptSession(1);
appendConsole(1, "stdout", text);
const view = render(
<>
<DebugConsole session={session} />
<ContextMenuHost />
<div data-testid="outside">outside text</div>
</>,
);
const log = screen.getByRole("log");
return { session, log, view };
}
function openFind(log: HTMLElement) {
fireEvent.keyDown(log, { key: "f", code: "KeyF", metaKey: true });
return screen.getByRole("search");
}
function input() {
return screen.getByRole("textbox", { name: "Find in console" });
}
function type(query: string) {
fireEvent.change(input(), { target: { value: query } });
}
function markCount(container: HTMLElement) {
return container.querySelectorAll("[data-find-match]").length;
}
describe("Debug console find", () => {
it("opens on ⌘F, searches as you type and highlights every match", () => {
const { log, view } = setup();
expect(openFind(log)).toBeInTheDocument();
expect(input()).toHaveFocus();
type("beta");
expect(screen.getByText("1/3")).toBeInTheDocument();
expect(markCount(view.container)).toBe(3);
expect(view.container.querySelectorAll("[data-find-current]")).toHaveLength(1);
});
it("moves through matches with Enter / Shift+Enter and wraps around", () => {
const { log } = setup();
openFind(log);
type("beta");
const bar = screen.getByRole("search");
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.keyDown(bar, { key: "Enter" });
expect(screen.getByText("2/3")).toBeInTheDocument();
fireEvent.keyDown(bar, { key: "Enter", shiftKey: true });
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.keyDown(bar, { key: "Enter", shiftKey: true });
expect(screen.getByText("3/3")).toBeInTheDocument();
});
it("supports match case and regex", () => {
const { log, view } = setup();
openFind(log);
type("beta");
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Match Case" }));
expect(screen.getByText("1/2")).toBeInTheDocument();
expect(markCount(view.container)).toBe(2);
fireEvent.change(input(), { target: { value: "(" } });
fireEvent.click(screen.getByRole("button", { name: "Regex" }));
expect(screen.getByText("Bad regex")).toBeInTheDocument();
});
it("closes on Escape, clears the highlights and returns focus to the console", () => {
const { log, view } = setup();
openFind(log);
type("beta");
expect(markCount(view.container)).toBe(3);
fireEvent.keyDown(input(), { key: "Escape" });
expect(screen.queryByRole("search")).not.toBeInTheDocument();
expect(markCount(view.container)).toBe(0);
expect(log).toHaveFocus();
});
it("offers the Run console's context menu", () => {
const { log } = setup();
fireEvent.contextMenu(log);
for (const label of ["Copy", "Select All", "Copy All", "Find…", "Clear All"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
// Nothing is selected, so Copy has nothing to copy.
expect(screen.getByText("Copy").closest("[aria-disabled]")).not.toBeNull();
// Send EOF is the Run console's stdin; the debug console has no such input.
expect(screen.queryByText("Send EOF")).not.toBeInTheDocument();
});
it("opens the find bar from the context menu", () => {
const { log } = setup();
fireEvent.contextMenu(log);
fireEvent.click(screen.getByText("Find…"));
expect(screen.getByRole("search")).toBeInTheDocument();
expect(input()).toHaveFocus();
});
it("copies the whole buffer without doubling the line breaks", async () => {
// Console lines keep their trailing "\n", so joining them with one would add blank lines.
const { log } = setup();
fireEvent.contextMenu(log);
fireEvent.click(screen.getByText("Copy All"));
await waitFor(() => expect(clipboard.writeText).toHaveBeenCalledWith("alpha beta\ngamma Beta\nbeta again\n"));
});
it("leaves Enter over a toggle button to the button, not to the next match", () => {
const { log } = setup();
openFind(log);
type("beta");
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("button", { name: "Match Case" }), { key: "Enter" });
expect(screen.getByText("1/3")).toBeInTheDocument();
});
it("keeps the span's styling on a highlighted match", () => {
const { log, view } = setup("\u001b[3mitalic beta\u001b[0m\n");
openFind(log);
type("beta");
const mark = view.container.querySelector("[data-find-match]") as HTMLElement;
expect(mark).not.toBeNull();
expect(mark.style.fontStyle).toBe("italic");
});
it("keeps Copy disabled when the selection is outside the console", () => {
const { log } = setup();
const outside = screen.getByTestId("outside");
const range = document.createRange();
range.selectNodeContents(outside);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
fireEvent.contextMenu(log);
expect(screen.getByText("Copy").closest("[aria-disabled]")).not.toBeNull();
});
});
+275 -14
View File
@@ -1,17 +1,95 @@
import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { copySelectionOnKeyDown, copyText } from "@/ui/selectableText";
import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui";
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { spanStyle } from "@/ui/ansi";
import { spanStyle, type Span, type SpanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import {
DEFAULT_FIND_OPTIONS,
findInLines,
type ConsoleMatch,
type FindOptions,
type FindResult,
} from "@/ui/consoleFind";
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import { useExpressionCompletion } from "./ExpressionCompletion";
import styles from "./DebugToolWindow.module.css";
const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
const MARK_COLOR = "var(--bg-search-match)";
const CURRENT_MARK_COLOR = "var(--bg-search-match-current)";
interface Segment {
text: string;
style: SpanStyle;
/** The piece is inside a find match. */
mark: boolean;
/** …and inside the current one. */
current: boolean;
}
/** Splits a line's styled spans at find-match boundaries (the Run console's `segmentLine`, without links). */
function findSegments(spans: readonly Span[], marks: readonly ConsoleMatch[], active?: ConsoleMatch): Segment[] {
const out: Segment[] = [];
let pos = 0;
let mi = 0;
for (const span of spans) {
const { text, ...style } = span;
let offset = 0;
while (offset < text.length) {
const abs = pos + offset;
while (mi < marks.length && marks[mi]!.end <= abs) mi++;
const mark = marks[mi];
if (mark && mark.start <= abs) {
const take = Math.min(mark.end - abs, text.length - offset);
out.push({ text: text.slice(offset, offset + take), style, mark: true, current: mark === active });
offset += take;
} else if (mark && mark.start < pos + text.length) {
const take = Math.min(mark.start - abs, text.length - offset);
out.push({ text: text.slice(offset, offset + take), style, mark: false, current: false });
offset += take;
} else {
out.push({ text: text.slice(offset), style, mark: false, current: false });
offset = text.length;
}
}
pos += text.length;
}
return out;
}
/** A match keeps the span's colours but takes the search background (as IDEA does). */
function segmentNode(seg: Segment, key: number) {
if (!seg.mark)
return (
<span key={key} style={spanStyle(seg.style)}>
{seg.text}
</span>
);
return (
<mark
key={key}
className={styles.findMatch}
style={{
...spanStyle(seg.style),
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
color: "var(--fg-search-match)",
}}
data-find-match=""
data-find-current={seg.current ? "" : undefined}
>
{seg.text}
</mark>
);
}
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
const session = useDebug((s) => s.sessions[sessionId]);
return session ? (
@@ -24,6 +102,7 @@ export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) {
const lines = useDebug((s) => s.console[session.id]);
const scroller = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
// Scroll to End: follows new output; scrolling up turns it off, back to the end turns it on.
const [follow, setFollow] = useState(true);
const stick = useRef(true);
@@ -39,6 +118,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const inputRef = useRef<HTMLInputElement>(null);
const completion = useExpressionCompletion(inputRef, input, setInput, session);
const [findOpen, setFindOpen] = useState(false);
const [query, setQuery] = useState("");
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
const [activeMatchIndex, setActiveMatchIndex] = useState(0);
const findInput = useRef<HTMLInputElement>(null);
/** Line the next search starts from (the top of the viewport when the query changed). */
const findAnchor = useRef(0);
const scrolledMatch = useRef<string | null>(null);
const virtualizer = useVirtualizer({
count: lines?.length ?? 0,
getScrollElement: () => scroller.current,
@@ -63,6 +151,99 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
});
}, [lines, virtualizer, follow, softWrap]);
// Find (⌘F): search the whole buffer, not just the rendered rows.
const found = useMemo(
() => (findOpen ? findInLines(lines ?? [], query, findOptions) : EMPTY_FIND),
[findOpen, lines, query, findOptions],
);
const activeIndex = found.matches.length ? Math.min(Math.max(activeMatchIndex, 0), found.matches.length - 1) : -1;
const current = activeIndex >= 0 ? found.matches[activeIndex] : undefined;
const marksByLine = useMemo(() => {
const byLine = new Map<number, ConsoleMatch[]>();
found.matches.forEach((match) => {
const marks = byLine.get(match.line);
if (marks) marks.push(match);
else byLine.set(match.line, [match]);
});
return byLine;
}, [found]);
// The line at the top of the viewport: typing searches from there, as IDEA does.
const topLine = () => {
const offset = scroller.current?.scrollTop ?? 0;
const items = virtualizer.getVirtualItems();
const first = items.find((item) => item.start + item.size > offset) ?? items[0];
return first?.index ?? 0;
};
const openFind = () => {
findAnchor.current = topLine();
// Opening with selected output searches for it (a single line, like the terminal).
if (!findOpen) {
const text = selectedText();
if (text) setQuery(text);
}
scrolledMatch.current = null;
setFindOpen(true);
setFollow(false);
findInput.current?.focus();
findInput.current?.select();
};
const closeFind = () => {
scrolledMatch.current = null;
setFindOpen(false);
scroller.current?.focus();
};
const changeQuery = (value: string) => {
findAnchor.current = topLine();
setQuery(value);
};
const changeOptions = (next: FindOptions) => {
findAnchor.current = topLine();
setFindOptions(next);
};
const goToMatch = (direction: 1 | -1) => {
const total = found.matches.length;
if (!total) return;
const from = activeMatchIndex >= 0 && activeMatchIndex < total ? activeMatchIndex : activeIndex;
setActiveMatchIndex((from + direction + total) % total);
};
const findWasOpen = useRef(false);
useEffect(() => {
if (findOpen) {
findWasOpen.current = true;
findInput.current?.focus();
findInput.current?.select();
} else if (findWasOpen.current) {
// Closed from the toolbar rather than the bar: put the caret back in the console.
findWasOpen.current = false;
scroller.current?.focus();
}
}, [findOpen]);
// The bar owns Escape / Enter / F3 / ⌘G while it is focused; keep the IDE keymap out.
useEffect(() => {
if (!findOpen) return;
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-console-find]"));
}, [findOpen]);
// A new query (or option) starts from the viewport, not from the top of the output.
useEffect(() => {
if (!findOpen) return;
const next = found.matches.findIndex((match) => match.line >= findAnchor.current);
setActiveMatchIndex(next >= 0 ? next : 0);
// eslint-disable-next-line react-hooks/exhaustive-deps -- new output keeps the current match
}, [findOpen, query, findOptions]);
// Bring the current match into view.
useEffect(() => {
if (!findOpen || !current) return;
const key = `${current.lineId}:${current.start}`;
if (scrolledMatch.current === key) return;
virtualizer.scrollToIndex(current.line, { align: "center" });
scrolledMatch.current = key;
}, [findOpen, current, virtualizer]);
const submit = async () => {
const e = input.trim();
if (!e) return;
@@ -112,8 +293,95 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
}
};
/** Rows are virtualized, so "everything" is a selection of the container, not of the mounted rows. */
const selectAll = () => {
if (inner.current) window.getSelection()?.selectAllChildren(inner.current);
};
const allSelected = () => {
const el = inner.current;
const s = window.getSelection();
return (
!!el &&
!!s &&
!s.isCollapsed &&
s.anchorNode === el &&
s.focusNode === el &&
s.anchorOffset === 0 &&
s.focusOffset === el.childNodes.length
);
};
const bufferText = () => (lines ?? []).map((l) => l.text).join("");
/** The selected text; after Select All the whole buffer, including rows that are not mounted. */
const selectedOutput = () => {
if (allSelected()) return bufferText();
const s = window.getSelection();
const el = scroller.current;
if (!s || !el || !s.anchorNode || !el.contains(s.anchorNode)) return "";
return s.toString();
};
const selectedText = () => {
const el = scroller.current;
const selection = window.getSelection();
if (!el || !selection || !selection.anchorNode || !el.contains(selection.anchorNode)) return "";
const text = selection.toString();
return text && !text.includes("\n") ? text.trim() : "";
};
const onOutputKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing) return;
if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey) {
const key = e.key.toLowerCase();
if (key === "f") {
e.preventDefault();
openFind();
return;
}
if (key === "a") {
e.preventDefault();
selectAll();
return;
}
}
copySelectionOnKeyDown(e, selectedOutput);
};
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
const selection = selectedOutput();
showContextMenu(e, [
{
label: "Copy",
icon: "copy",
shortcut: formatBinding(hostBinding("Meta+C")),
disabled: !selection,
run: () => void copyText(selection),
},
{ label: "Select All", shortcut: formatBinding(hostBinding("Meta+A")), run: selectAll },
{ label: "Copy All", run: () => void copyText(bufferText()) },
{ label: "Find…", icon: "search", shortcut: formatBinding(hostBinding("Meta+F")), run: openFind },
{ kind: "separator" },
{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) },
]);
};
return (
<div className={styles.console}>
{findOpen && (
<ConsoleFindBar
query={query}
onChange={changeQuery}
options={findOptions}
onOptionsChange={changeOptions}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
/>
)}
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
@@ -123,19 +391,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
}}
onContextMenu={onContextMenu}
role="log"
onKeyDown={copySelectionOnKeyDown}
onKeyDown={onOutputKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
@@ -147,11 +412,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{l.spans.map((sp, i) => (
<span key={i} style={spanStyle(sp)}>
{sp.text}
</span>
))}
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
</span>
);
})}
@@ -251,6 +251,11 @@
cursor: text;
}
/* Find matches in the console output. */
.findMatch {
border-radius: 2px;
}
/* Soft-Wrap off: long lines scroll horizontally. */
.noWrap {
white-space: pre;
+3 -3
View File
@@ -24,8 +24,8 @@ import styles from "./Run.module.css";
import { useConsoleSelection } from "./useConsoleSelection";
import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { ConsoleFindBar } from "./FindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "./find";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "@/ui/consoleFind";
const ROW = 20;
@@ -343,7 +343,7 @@ export function ConsoleView({
// The bar owns Escape / Enter / F3 / ⌘G while it is focused; keep the IDE keymap out.
useEffect(() => {
if (!findOpen) return;
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-run-find]"));
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-console-find]"));
}, [findOpen]);
// A new query (or option) starts from the viewport, not from the top of the output.
-29
View File
@@ -780,35 +780,6 @@
background: transparent;
}
/* Find bar (⌘F) */
.find {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.findField {
flex: 0 1 360px;
min-width: 160px;
}
.findCount {
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-subtle);
padding: 0 6px;
white-space: nowrap;
min-width: 56px;
}
.findCountNone {
color: var(--danger);
}
.findSpacer {
flex: 1;
}
.console:focus .inputCaret {
background: var(--fg);
animation: caretBlink 1s steps(1) infinite;
+28
View File
@@ -0,0 +1,28 @@
/* Find bar (⌘F) of the Run and Debug consoles. */
.find {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.findField {
flex: 0 1 360px;
min-width: 160px;
}
.findCount {
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-subtle);
padding: 0 6px;
white-space: nowrap;
min-width: 56px;
}
.findCountNone {
color: var(--danger);
}
.findSpacer {
flex: 1;
}
@@ -1,11 +1,12 @@
/** ⌘F in the Run console: the IDEA find bar (Match Case / Words / Regex, n/m, previous/next). */
/** ⌘F in a console (Run, Debug): the IDEA find bar (Match Case / Words / Regex, n/m, previous/next). */
import { type KeyboardEvent, type RefObject } from "react";
import clsx from "clsx";
import { IconButton, SearchField } from "@/ui";
import { IconButton } from "./Button";
import { SearchField } from "./Input";
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import type { FindOptions } from "./find";
import styles from "./Run.module.css";
import type { FindOptions } from "./consoleFind";
import styles from "./ConsoleFindBar.module.css";
export interface ConsoleFindBarProps {
query: string;
@@ -55,6 +56,8 @@ export function ConsoleFindBar({
e.stopPropagation();
onClose();
} else if (e.key === "Enter") {
// Enter on a button (Match Case, Close, …) must activate it, not jump to the next match.
if ((e.target as Element | null)?.closest?.("button")) return;
e.preventDefault();
if (e.shiftKey) onPrevious();
else onNext();
@@ -81,7 +84,7 @@ export function ConsoleFindBar({
className={styles.find}
role="search"
aria-label={tx("Find in console")}
data-run-find=""
data-console-find=""
onKeyDown={onKeyDown}
>
<SearchField
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_FIND_OPTIONS, findInLines, MAX_MATCHES, type FindOptions } from "./find";
import { DEFAULT_FIND_OPTIONS, findInLines, MAX_MATCHES, type FindOptions } from "./consoleFind";
const lines = (...texts: string[]) => texts.map((text, i) => ({ id: i + 1, text }));
const opts = (extra: Partial<FindOptions> = {}): FindOptions => ({ ...DEFAULT_FIND_OPTIONS, ...extra });
@@ -1,5 +1,5 @@
/**
* Find in the Run console output: every occurrence in every line, with IDEA's
* Find in console output (Run, Debug): every occurrence in every line, with IDEA's
* Match Case / Words / Regex options. Matches carry the line's stable id so the
* bar can keep the current match while output keeps arriving.
*/
@@ -17,7 +17,7 @@ export const DEFAULT_FIND_OPTIONS: FindOptions = {
};
export interface ConsoleMatch {
/** Index into the model's current lines. */
/** Index into the console's current lines. */
line: number;
/** Stable line id (survives trimming of older lines). */
lineId: number;