[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
596
components/terminal/pluginTerminalVisualProviderHost.ts
Normal file
596
components/terminal/pluginTerminalVisualProviderHost.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import type { IDecoration, IDisposable, IMarker, Terminal as XTerm } from '@xterm/xterm';
|
||||
|
||||
import {
|
||||
normalizePluginBackgroundResult,
|
||||
normalizePluginBackgroundRefreshAfterMs,
|
||||
normalizePluginMatcherResult,
|
||||
normalizePluginPromptResult,
|
||||
normalizePluginSemanticResult,
|
||||
type PluginTerminalAnnotation,
|
||||
} from '../../domain/pluginTerminalProviders';
|
||||
import {
|
||||
waitForPluginTerminalProviderResponse,
|
||||
type PluginTerminalProviderCallResponse,
|
||||
type RequestPluginTerminalProviders,
|
||||
} from './pluginTerminalLinkProvider';
|
||||
import {
|
||||
pluginTerminalCellRange,
|
||||
readPluginTerminalBufferText,
|
||||
type PluginTerminalBufferText,
|
||||
} from './pluginTerminalBufferText';
|
||||
import { detectPrompt } from './autocomplete/promptDetector';
|
||||
|
||||
const MATCHER_QUIET_MS = 220;
|
||||
const PROVIDER_DEADLINE_MS = 1_000;
|
||||
const PROVIDER_RESPONSE_TIMEOUT_MS = 1_200;
|
||||
const MAX_VISIBLE_ANNOTATIONS = 8;
|
||||
const MAX_MATCHER_LINES = 32;
|
||||
const MAX_MATCHER_PHYSICAL_LINES = 256;
|
||||
const MAX_MATCHER_BATCH_CHARS = 96 * 1024;
|
||||
|
||||
const severityColors = Object.freeze({
|
||||
info: '#60a5fa',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
success: '#22c55e',
|
||||
});
|
||||
|
||||
interface MatcherLine {
|
||||
readonly lineId: string;
|
||||
readonly line: string;
|
||||
readonly bufferLineNumber: number;
|
||||
readonly segments: readonly {
|
||||
absoluteY: number;
|
||||
start: number;
|
||||
length: number;
|
||||
text: PluginTerminalBufferText;
|
||||
}[];
|
||||
}
|
||||
|
||||
function recentLogicalLines(term: XTerm): readonly MatcherLine[] {
|
||||
const buffer = term.buffer.active;
|
||||
const lastAbsoluteY = buffer.baseY + buffer.cursorY;
|
||||
let firstAbsoluteY = Math.max(0, lastAbsoluteY - MAX_MATCHER_PHYSICAL_LINES + 1);
|
||||
while (firstAbsoluteY > 0
|
||||
&& lastAbsoluteY - firstAbsoluteY < MAX_MATCHER_PHYSICAL_LINES
|
||||
&& buffer.getLine(firstAbsoluteY)?.isWrapped) {
|
||||
firstAbsoluteY -= 1;
|
||||
}
|
||||
const lines: MatcherLine[] = [];
|
||||
let skipPartialLogicalLine = buffer.getLine(firstAbsoluteY)?.isWrapped === true;
|
||||
let current: {
|
||||
startAbsoluteY: number;
|
||||
line: string;
|
||||
segments: Array<{
|
||||
absoluteY: number;
|
||||
start: number;
|
||||
length: number;
|
||||
text: PluginTerminalBufferText;
|
||||
}>;
|
||||
} | null = null;
|
||||
for (let absoluteY = firstAbsoluteY; absoluteY <= lastAbsoluteY; absoluteY += 1) {
|
||||
const bufferLine = buffer.getLine(absoluteY);
|
||||
if (!bufferLine) continue;
|
||||
if (skipPartialLogicalLine) {
|
||||
if (bufferLine.isWrapped) continue;
|
||||
skipPartialLogicalLine = false;
|
||||
}
|
||||
if (!bufferLine.isWrapped || !current) {
|
||||
if (current?.line && current.line.length <= 8_192) {
|
||||
const endAbsoluteY = current.segments.at(-1)?.absoluteY ?? current.startAbsoluteY;
|
||||
lines.push({
|
||||
lineId: `${current.startAbsoluteY}:${endAbsoluteY}`,
|
||||
line: current.line,
|
||||
bufferLineNumber: current.startAbsoluteY + 1,
|
||||
segments: Object.freeze(current.segments),
|
||||
});
|
||||
}
|
||||
current = { startAbsoluteY: absoluteY, line: '', segments: [] };
|
||||
}
|
||||
const nextIsWrapped = buffer.getLine(absoluteY + 1)?.isWrapped === true;
|
||||
const segment = readPluginTerminalBufferText(bufferLine, !nextIsWrapped);
|
||||
const start = current.line.length;
|
||||
current.line += segment.text;
|
||||
current.segments.push({ absoluteY, start, length: segment.text.length, text: segment });
|
||||
}
|
||||
if (current?.line && current.line.length <= 8_192) {
|
||||
const endAbsoluteY = current.segments.at(-1)?.absoluteY ?? current.startAbsoluteY;
|
||||
lines.push({
|
||||
lineId: `${current.startAbsoluteY}:${endAbsoluteY}`,
|
||||
line: current.line,
|
||||
bufferLineNumber: current.startAbsoluteY + 1,
|
||||
segments: Object.freeze(current.segments),
|
||||
});
|
||||
}
|
||||
const selected: MatcherLine[] = [];
|
||||
let characterCount = 0;
|
||||
for (const line of lines.slice(-MAX_MATCHER_LINES).reverse()) {
|
||||
if (characterCount + line.line.length > MAX_MATCHER_BATCH_CHARS) break;
|
||||
selected.unshift(line);
|
||||
characterCount += line.line.length;
|
||||
}
|
||||
return Object.freeze(selected);
|
||||
}
|
||||
|
||||
function currentPromptLine(term: XTerm): { line: string; bufferLineNumber: number } | null {
|
||||
const prompt = detectPrompt(term);
|
||||
if (!prompt.isAtPrompt || prompt.userInput.length > 0) return null;
|
||||
const buffer = term.buffer.active;
|
||||
const absoluteY = buffer.baseY + buffer.cursorY;
|
||||
const line = prompt.promptText.trimEnd();
|
||||
if (!line || line.length > 8_192) return null;
|
||||
return { line, bufferLineNumber: absoluteY + 1 };
|
||||
}
|
||||
|
||||
function disposeAll(values: Array<IDisposable | undefined>): void {
|
||||
for (const value of values.splice(0)) {
|
||||
try { value?.dispose(); } catch { /* isolate xterm decoration cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
readonly #term: XTerm;
|
||||
readonly #request: RequestPluginTerminalProviders;
|
||||
readonly #disposables: IDisposable[] = [];
|
||||
readonly #matcherDecorations: Array<IDecoration | IMarker | undefined> = [];
|
||||
readonly #annotationDecorations: Array<IDecoration | IMarker | undefined> = [];
|
||||
#visibleAnnotations: readonly PluginTerminalAnnotation[] = Object.freeze([]);
|
||||
#annotationAnchorLine: number | null = null;
|
||||
#annotationMarker: IMarker | null = null;
|
||||
readonly #requestControllers = new Set<AbortController>();
|
||||
readonly #matcherRequestControllers = new Set<AbortController>();
|
||||
#matcherTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
#matcherGeneration = 0;
|
||||
#annotationGeneration = 0;
|
||||
#backgroundGeneration = 0;
|
||||
readonly #pendingSemantics: Array<{
|
||||
annotations: readonly PluginTerminalAnnotation[];
|
||||
ready: Promise<void>;
|
||||
}> = [];
|
||||
#backgroundOverlay: HTMLDivElement | null = null;
|
||||
#backgroundTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
#terminalBackground: string | undefined;
|
||||
#active: boolean;
|
||||
#visible: boolean;
|
||||
#reducedMotion: boolean;
|
||||
readonly #isProviderAvailable: (kind: NetcattyTerminalProviderKind) => boolean;
|
||||
#providerAvailabilityGeneration = 0;
|
||||
#disposed = false;
|
||||
readonly #matcherQuietMs: number;
|
||||
readonly #providerResponseTimeoutMs: number;
|
||||
|
||||
constructor(options: {
|
||||
term: XTerm;
|
||||
request: RequestPluginTerminalProviders;
|
||||
matcherQuietMs?: number;
|
||||
providerResponseTimeoutMs?: number;
|
||||
terminalBackground?: string;
|
||||
active?: boolean;
|
||||
visible?: boolean;
|
||||
reducedMotion?: boolean;
|
||||
isProviderAvailable?(kind: NetcattyTerminalProviderKind): boolean;
|
||||
}) {
|
||||
this.#term = options.term;
|
||||
this.#request = options.request;
|
||||
this.#matcherQuietMs = options.matcherQuietMs ?? MATCHER_QUIET_MS;
|
||||
this.#providerResponseTimeoutMs = options.providerResponseTimeoutMs ?? PROVIDER_RESPONSE_TIMEOUT_MS;
|
||||
this.#terminalBackground = options.terminalBackground;
|
||||
this.#active = options.active ?? true;
|
||||
this.#visible = options.visible ?? true;
|
||||
const reducedMotionQuery = options.reducedMotion === undefined
|
||||
&& typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
: null;
|
||||
this.#reducedMotion = options.reducedMotion ?? reducedMotionQuery?.matches ?? false;
|
||||
if (reducedMotionQuery) {
|
||||
const onReducedMotionChanged = (event: MediaQueryListEvent) => {
|
||||
this.#reducedMotion = event.matches;
|
||||
clearTimeout(this.#backgroundTimer);
|
||||
this.#backgroundTimer = undefined;
|
||||
if (!event.matches && this.#active && this.#visible) {
|
||||
void this.refreshBackground('reduced-motion-changed', this.#terminalBackground);
|
||||
}
|
||||
};
|
||||
reducedMotionQuery.addEventListener('change', onReducedMotionChanged);
|
||||
this.#disposables.push({
|
||||
dispose: () => reducedMotionQuery.removeEventListener('change', onReducedMotionChanged),
|
||||
});
|
||||
}
|
||||
this.#isProviderAvailable = options.isProviderAvailable ?? (() => true);
|
||||
this.#disposables.push(this.#term.onWriteParsed(() => {
|
||||
if (this.#annotationMarker && !this.#annotationMarker.isDisposed) {
|
||||
this.#annotationAnchorLine = this.#annotationMarker.line;
|
||||
}
|
||||
this.#matcherGeneration += 1;
|
||||
for (const controller of this.#matcherRequestControllers) controller.abort();
|
||||
this.#matcherRequestControllers.clear();
|
||||
disposeAll(this.#matcherDecorations);
|
||||
this.#scheduleMatcherRefresh();
|
||||
}));
|
||||
if (this.#active && this.#visible && this.#isProviderAvailable('terminal.background')) {
|
||||
void this.refreshBackground('runtime-created', options.terminalBackground);
|
||||
}
|
||||
}
|
||||
|
||||
async #requestWithTimeout(
|
||||
kind: NetcattyTerminalProviderKind,
|
||||
operation: string,
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
supersessionKey?: string,
|
||||
): Promise<PluginTerminalProviderCallResponse> {
|
||||
const controller = new AbortController();
|
||||
this.#requestControllers.add(controller);
|
||||
if (kind === 'terminal.matcher') this.#matcherRequestControllers.add(controller);
|
||||
try {
|
||||
return await waitForPluginTerminalProviderResponse(
|
||||
this.#request(
|
||||
kind,
|
||||
operation,
|
||||
payload,
|
||||
PROVIDER_DEADLINE_MS,
|
||||
supersessionKey,
|
||||
controller.signal,
|
||||
),
|
||||
this.#providerResponseTimeoutMs,
|
||||
() => controller.abort(),
|
||||
);
|
||||
} finally {
|
||||
this.#requestControllers.delete(controller);
|
||||
this.#matcherRequestControllers.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
#abortProviderRequests(): void {
|
||||
for (const controller of this.#requestControllers) controller.abort();
|
||||
this.#requestControllers.clear();
|
||||
this.#matcherRequestControllers.clear();
|
||||
}
|
||||
|
||||
async commandSubmitted(command: string): Promise<void> {
|
||||
if (this.#disposed || !this.#active || !this.#visible || !this.#isProviderAvailable('terminal.semantic')
|
||||
|| command.length < 1 || command.length > 4_096) return;
|
||||
if (this.#pendingSemantics.length >= 64) return;
|
||||
const providerGeneration = this.#providerAvailabilityGeneration;
|
||||
let resolveReady: (() => void) | undefined;
|
||||
const pending = {
|
||||
annotations: Object.freeze([]) as readonly PluginTerminalAnnotation[],
|
||||
ready: new Promise<void>((resolve) => { resolveReady = resolve; }),
|
||||
};
|
||||
this.#pendingSemantics.push(pending);
|
||||
try {
|
||||
const response = await this.#requestWithTimeout(
|
||||
'terminal.semantic',
|
||||
'provideSemantics',
|
||||
{ command },
|
||||
);
|
||||
if (this.#disposed || !this.#active || !this.#visible || response.stale
|
||||
|| providerGeneration !== this.#providerAvailabilityGeneration) return;
|
||||
const annotations = response.results.flatMap((result) => {
|
||||
if (result.status !== 'ok') return [];
|
||||
const semantic = normalizePluginSemanticResult(result.providerId, result.result);
|
||||
const summary = semantic.classification
|
||||
? [{
|
||||
text: semantic.destructive
|
||||
? `[destructive] ${semantic.classification}`
|
||||
: semantic.classification,
|
||||
...(semantic.destructive ? { color: severityColors.error } : {}),
|
||||
...(semantic.description ? { description: semantic.description } : {}),
|
||||
providerId: result.providerId,
|
||||
}]
|
||||
: [];
|
||||
return [...summary, ...semantic.annotations];
|
||||
});
|
||||
pending.annotations = Object.freeze(annotations.slice(0, MAX_VISIBLE_ANNOTATIONS));
|
||||
} catch { /* a missing semantic result leaves this command unannotated */ }
|
||||
finally { resolveReady?.(); }
|
||||
}
|
||||
|
||||
async commandCompleted(): Promise<void> {
|
||||
if (this.#disposed || !this.#active || !this.#visible) return;
|
||||
const generation = ++this.#annotationGeneration;
|
||||
const pendingSemantic = this.#pendingSemantics.shift();
|
||||
await pendingSemantic?.ready;
|
||||
if (this.#disposed || !this.#active || !this.#visible
|
||||
|| generation !== this.#annotationGeneration) return;
|
||||
const semanticAnnotations = pendingSemantic?.annotations ?? Object.freeze([]);
|
||||
if (!this.#isProviderAvailable('terminal.prompt')) {
|
||||
this.#renderAnnotations(semanticAnnotations);
|
||||
return;
|
||||
}
|
||||
const prompt = currentPromptLine(this.#term);
|
||||
try {
|
||||
const response = await this.#requestWithTimeout(
|
||||
'terminal.prompt',
|
||||
'provideAnnotations',
|
||||
{
|
||||
reason: 'commandCompleted',
|
||||
...(prompt ? { promptLine: prompt.line, bufferLineNumber: prompt.bufferLineNumber } : {}),
|
||||
},
|
||||
);
|
||||
if (this.#disposed || !this.#active || !this.#visible || response.stale
|
||||
|| generation !== this.#annotationGeneration) return;
|
||||
const promptAnnotations = response.results.flatMap((result) => result.status === 'ok'
|
||||
? normalizePluginPromptResult(result.providerId, result.result)
|
||||
: []);
|
||||
this.#renderAnnotations([...semanticAnnotations, ...promptAnnotations].slice(0, MAX_VISIBLE_ANNOTATIONS));
|
||||
} catch {
|
||||
if (!this.#disposed && this.#active && this.#visible
|
||||
&& generation === this.#annotationGeneration) {
|
||||
this.#renderAnnotations(semanticAnnotations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshBackground(reason: string, terminalBackground?: string): Promise<void> {
|
||||
if (terminalBackground) this.#terminalBackground = terminalBackground;
|
||||
clearTimeout(this.#backgroundTimer);
|
||||
this.#backgroundTimer = undefined;
|
||||
if (this.#disposed || !this.#active || !this.#visible
|
||||
|| !this.#isProviderAvailable('terminal.background')) {
|
||||
this.#renderBackground([]);
|
||||
return;
|
||||
}
|
||||
const generation = ++this.#backgroundGeneration;
|
||||
try {
|
||||
const response = await this.#requestWithTimeout(
|
||||
'terminal.background',
|
||||
'provideBackgrounds',
|
||||
{ reason, ...(terminalBackground ? { terminalBackground } : {}) },
|
||||
);
|
||||
if (this.#disposed || !this.#active || !this.#visible || response.stale
|
||||
|| generation !== this.#backgroundGeneration) return;
|
||||
const layers = response.results.flatMap((result) => result.status === 'ok'
|
||||
? normalizePluginBackgroundResult(result.providerId, result.result)
|
||||
: []).slice(0, 4);
|
||||
this.#renderBackground(layers.map((layer) => ({ color: layer.color, opacity: layer.opacity })));
|
||||
if (!this.#reducedMotion) {
|
||||
const refreshAfterMs = response.results.reduce<number | undefined>((minimum, result) => {
|
||||
if (result.status !== 'ok') return minimum;
|
||||
const next = normalizePluginBackgroundRefreshAfterMs(result.result);
|
||||
return next === undefined ? minimum : Math.min(minimum ?? next, next);
|
||||
}, undefined);
|
||||
if (refreshAfterMs !== undefined) {
|
||||
this.#backgroundTimer = setTimeout(() => {
|
||||
this.#backgroundTimer = undefined;
|
||||
void this.refreshBackground('scheduled', this.#terminalBackground);
|
||||
}, refreshAfterMs);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!this.#disposed && this.#active && this.#visible
|
||||
&& generation === this.#backgroundGeneration) this.#renderBackground([]);
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleMatcherRefresh(): void {
|
||||
if (this.#disposed || !this.#active || !this.#visible
|
||||
|| !this.#isProviderAvailable('terminal.matcher')) return;
|
||||
clearTimeout(this.#matcherTimer);
|
||||
this.#matcherTimer = setTimeout(() => { void this.#refreshMatchers(); }, this.#matcherQuietMs);
|
||||
}
|
||||
|
||||
async #refreshMatchers(): Promise<void> {
|
||||
this.#matcherTimer = undefined;
|
||||
if (this.#disposed || !this.#active || !this.#visible
|
||||
|| !this.#isProviderAvailable('terminal.matcher')) return;
|
||||
if (this.#term.buffer.active.type === 'alternate') {
|
||||
disposeAll(this.#matcherDecorations);
|
||||
return;
|
||||
}
|
||||
const lines = recentLogicalLines(this.#term);
|
||||
if (lines.length === 0) return;
|
||||
const lineLengths = new Map(lines.map((line) => [line.lineId, line.line.length]));
|
||||
const matcherLines = new Map(lines.map((line) => [line.lineId, line]));
|
||||
const generation = ++this.#matcherGeneration;
|
||||
try {
|
||||
const response = await this.#requestWithTimeout(
|
||||
'terminal.matcher',
|
||||
'provideMatches',
|
||||
{ lines: lines.map(({ lineId, line, bufferLineNumber }) => ({ lineId, line, bufferLineNumber })) },
|
||||
);
|
||||
if (this.#disposed || !this.#active || !this.#visible || response.stale
|
||||
|| generation !== this.#matcherGeneration) return;
|
||||
if (this.#term.buffer.active.type === 'alternate') {
|
||||
disposeAll(this.#matcherDecorations);
|
||||
return;
|
||||
}
|
||||
const matches = response.results.flatMap((result) => result.status === 'ok'
|
||||
? normalizePluginMatcherResult(result.providerId, result.result, lineLengths)
|
||||
: []).slice(0, 64);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
const cursorAbsoluteY = this.#term.buffer.active.baseY + this.#term.buffer.active.cursorY;
|
||||
for (const match of matches) {
|
||||
const line = matcherLines.get(match.lineId);
|
||||
if (!line) continue;
|
||||
const matchEnd = match.start + match.length;
|
||||
for (const segment of line.segments) {
|
||||
const segmentEnd = segment.start + segment.length;
|
||||
const overlapStart = Math.max(match.start, segment.start);
|
||||
const overlapEnd = Math.min(matchEnd, segmentEnd);
|
||||
if (overlapStart >= overlapEnd) continue;
|
||||
const cellRange = pluginTerminalCellRange(
|
||||
segment.text,
|
||||
overlapStart - segment.start,
|
||||
overlapEnd - overlapStart,
|
||||
);
|
||||
if (!cellRange) continue;
|
||||
const marker = this.#term.registerMarker(segment.absoluteY - cursorAbsoluteY);
|
||||
if (!marker) continue;
|
||||
const decoration = this.#term.registerDecoration({
|
||||
marker,
|
||||
x: cellRange.x,
|
||||
width: cellRange.width,
|
||||
foregroundColor: match.color ?? severityColors[match.severity],
|
||||
layer: 'top',
|
||||
});
|
||||
decoration?.onRender((element) => {
|
||||
element.setAttribute('aria-label', match.label);
|
||||
element.title = match.label;
|
||||
});
|
||||
this.#matcherDecorations.push(decoration, marker);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (this.#active && this.#visible && generation === this.#matcherGeneration) {
|
||||
disposeAll(this.#matcherDecorations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#renderAnnotations(
|
||||
annotations: readonly PluginTerminalAnnotation[],
|
||||
anchorLine?: number | null,
|
||||
): void {
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#annotationMarker = null;
|
||||
this.#visibleAnnotations = Object.freeze([...annotations]);
|
||||
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) {
|
||||
this.#annotationAnchorLine = null;
|
||||
return;
|
||||
}
|
||||
const buffer = this.#term.buffer.active;
|
||||
const cursorLine = buffer.baseY + buffer.cursorY;
|
||||
const marker = this.#term.registerMarker((anchorLine ?? cursorLine) - cursorLine);
|
||||
if (!marker) return;
|
||||
this.#annotationMarker = marker;
|
||||
this.#annotationAnchorLine = marker.line;
|
||||
const decoration = this.#term.registerDecoration({ marker, anchor: 'right', x: 0, width: 1, layer: 'top' });
|
||||
const text = annotations.map((annotation) => annotation.text).join(' | ').slice(0, 512);
|
||||
const description = annotations.map((annotation) => annotation.description).filter(Boolean).join('\n');
|
||||
decoration?.onRender((element) => {
|
||||
element.className = 'pointer-events-none max-w-[50%] overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium';
|
||||
element.textContent = text;
|
||||
element.setAttribute('role', 'note');
|
||||
element.setAttribute('aria-label', text);
|
||||
if (description) element.title = description;
|
||||
element.style.color = annotations[0]?.color ?? 'var(--muted-foreground)';
|
||||
element.style.width = 'max-content';
|
||||
element.style.maxWidth = '50%';
|
||||
element.style.transform = 'translateX(calc(-100% - 4px))';
|
||||
});
|
||||
this.#annotationDecorations.push(decoration, marker);
|
||||
}
|
||||
|
||||
/** Recreate markers disposed by a terminal history replay without re-querying plugins. */
|
||||
terminalRebuilt(): void {
|
||||
if (this.#visibleAnnotations.length === 0) return;
|
||||
this.#renderAnnotations(this.#visibleAnnotations, this.#annotationAnchorLine);
|
||||
}
|
||||
|
||||
#clearAnnotations(): void {
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#annotationMarker = null;
|
||||
this.#visibleAnnotations = Object.freeze([]);
|
||||
this.#annotationAnchorLine = null;
|
||||
}
|
||||
|
||||
setActive(active: boolean, terminalBackground?: string): void {
|
||||
if (this.#disposed || this.#active === active) {
|
||||
if (active && terminalBackground) void this.refreshBackground('theme-changed', terminalBackground);
|
||||
return;
|
||||
}
|
||||
this.#active = active;
|
||||
if (active) {
|
||||
void this.refreshBackground('session-connected', terminalBackground);
|
||||
this.#scheduleMatcherRefresh();
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.#matcherTimer);
|
||||
clearTimeout(this.#backgroundTimer);
|
||||
this.#matcherTimer = undefined;
|
||||
this.#backgroundTimer = undefined;
|
||||
this.#abortProviderRequests();
|
||||
this.#matcherGeneration += 1;
|
||||
this.#annotationGeneration += 1;
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#renderBackground([]);
|
||||
}
|
||||
|
||||
setVisible(visible: boolean, terminalBackground?: string): void {
|
||||
if (this.#disposed || this.#visible === visible) return;
|
||||
this.#visible = visible;
|
||||
clearTimeout(this.#backgroundTimer);
|
||||
clearTimeout(this.#matcherTimer);
|
||||
this.#backgroundTimer = undefined;
|
||||
this.#matcherTimer = undefined;
|
||||
this.#matcherGeneration += 1;
|
||||
this.#annotationGeneration += 1;
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
if (!visible) {
|
||||
disposeAll(this.#matcherDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#renderBackground([]);
|
||||
}
|
||||
if (visible && this.#active) {
|
||||
void this.refreshBackground('terminal-visible', terminalBackground ?? this.#terminalBackground);
|
||||
this.#scheduleMatcherRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
providerAvailabilityChanged(terminalBackground?: string): void {
|
||||
if (this.#disposed) return;
|
||||
this.#providerAvailabilityGeneration += 1;
|
||||
this.#matcherGeneration += 1;
|
||||
this.#annotationGeneration += 1;
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
this.#clearAnnotations();
|
||||
if (!this.#isProviderAvailable('terminal.background')) this.#renderBackground([]);
|
||||
if (!this.#isProviderAvailable('terminal.matcher')) disposeAll(this.#matcherDecorations);
|
||||
if (this.#active && this.#visible) {
|
||||
void this.refreshBackground('providers-changed', terminalBackground ?? this.#terminalBackground);
|
||||
this.#scheduleMatcherRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
#renderBackground(layers: readonly { color: string; opacity: number }[]): void {
|
||||
this.#backgroundOverlay?.remove();
|
||||
this.#backgroundOverlay = null;
|
||||
const root = this.#term.element;
|
||||
if (!root || layers.length === 0 || this.#disposed || !this.#active || !this.#visible) return;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.className = 'pointer-events-none absolute inset-0 z-[1]';
|
||||
const totalOpacity = layers.reduce((total, layer) => total + layer.opacity, 0);
|
||||
const opacityScale = totalOpacity > 0.35 ? 0.35 / totalOpacity : 1;
|
||||
overlay.style.background = layers
|
||||
.map((layer) => {
|
||||
const red = Number.parseInt(layer.color.slice(1, 3), 16);
|
||||
const green = Number.parseInt(layer.color.slice(3, 5), 16);
|
||||
const blue = Number.parseInt(layer.color.slice(5, 7), 16);
|
||||
const colorAlpha = layer.color.length === 9
|
||||
? Number.parseInt(layer.color.slice(7, 9), 16) / 255
|
||||
: 1;
|
||||
const alpha = layer.opacity * opacityScale * colorAlpha;
|
||||
const rgba = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
return `linear-gradient(${rgba}, ${rgba})`;
|
||||
})
|
||||
.join(', ');
|
||||
root.appendChild(overlay);
|
||||
this.#backgroundOverlay = overlay;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
clearTimeout(this.#matcherTimer);
|
||||
clearTimeout(this.#backgroundTimer);
|
||||
this.#matcherGeneration += 1;
|
||||
this.#annotationGeneration += 1;
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#backgroundOverlay?.remove();
|
||||
this.#backgroundOverlay = null;
|
||||
disposeAll(this.#disposables);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user