[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:
299
components/terminal/pluginTerminalLinkProvider.ts
Normal file
299
components/terminal/pluginTerminalLinkProvider.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type {
|
||||
IDisposable,
|
||||
ILink,
|
||||
ILinkProvider,
|
||||
Terminal as XTerm,
|
||||
} from '@xterm/xterm';
|
||||
|
||||
import {
|
||||
normalizePluginHoverResult,
|
||||
normalizePluginLinkResult,
|
||||
type PluginTerminalHover,
|
||||
} from '../../domain/pluginTerminalProviders';
|
||||
import {
|
||||
pluginTerminalCellRange,
|
||||
readPluginTerminalBufferText,
|
||||
type PluginTerminalBufferText,
|
||||
} from './pluginTerminalBufferText';
|
||||
|
||||
const MAX_PROVIDER_LINE_CHARS = 8_192;
|
||||
const PROVIDER_RESPONSE_TIMEOUT_MS = 800;
|
||||
|
||||
export interface PluginTerminalProviderCallResult {
|
||||
readonly providerId: string;
|
||||
readonly status: string;
|
||||
readonly result?: unknown;
|
||||
}
|
||||
|
||||
export interface PluginTerminalProviderCallResponse {
|
||||
readonly stale: boolean;
|
||||
readonly results: readonly PluginTerminalProviderCallResult[];
|
||||
}
|
||||
|
||||
export const ORDINARY_TERMINAL_PROVIDER_KINDS = Object.freeze([
|
||||
'terminal.completion',
|
||||
'terminal.decoration',
|
||||
'terminal.link',
|
||||
'terminal.hover',
|
||||
'terminal.matcher',
|
||||
'terminal.semantic',
|
||||
'terminal.prompt',
|
||||
'terminal.background',
|
||||
'terminal.theme',
|
||||
] as const satisfies readonly NetcattyTerminalProviderKind[]);
|
||||
|
||||
export type RequestPluginTerminalProviders = (
|
||||
kind: NetcattyTerminalProviderKind,
|
||||
operation: string,
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
deadlineMs: number,
|
||||
supersessionKey?: string,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<PluginTerminalProviderCallResponse>;
|
||||
|
||||
export interface PluginTerminalLinkProviderHost extends IDisposable {
|
||||
setActive(active: boolean): void;
|
||||
setVisible(visible: boolean): void;
|
||||
providerAvailabilityChanged(): void;
|
||||
}
|
||||
|
||||
function lineTextAt(term: XTerm, bufferLineNumber: number): PluginTerminalBufferText | null {
|
||||
const line = term.buffer.active.getLine(bufferLineNumber - 1);
|
||||
if (!line) return null;
|
||||
const text = readPluginTerminalBufferText(line, true);
|
||||
return text.text.length <= MAX_PROVIDER_LINE_CHARS ? text : null;
|
||||
}
|
||||
|
||||
function matchingHover(
|
||||
hovers: readonly PluginTerminalHover[],
|
||||
providerId: string,
|
||||
start: number,
|
||||
length: number,
|
||||
): PluginTerminalHover | undefined {
|
||||
return hovers.find((hover) => hover.providerId === providerId
|
||||
&& hover.start === start
|
||||
&& hover.length === length);
|
||||
}
|
||||
|
||||
export async function waitForPluginTerminalProviderResponse(
|
||||
request: Promise<PluginTerminalProviderCallResponse>,
|
||||
timeoutMs: number,
|
||||
onTimeout?: () => void,
|
||||
): Promise<PluginTerminalProviderCallResponse> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
request,
|
||||
new Promise<PluginTerminalProviderCallResponse>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout?.();
|
||||
resolve({ stale: false, results: Object.freeze([]) });
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function createTooltip(term: XTerm): {
|
||||
show(event: MouseEvent, contents: string): void;
|
||||
hide(): void;
|
||||
dispose(): void;
|
||||
} {
|
||||
let tooltip: HTMLDivElement | null = null;
|
||||
const hide = () => {
|
||||
tooltip?.remove();
|
||||
tooltip = null;
|
||||
};
|
||||
return {
|
||||
show(event, contents) {
|
||||
hide();
|
||||
const root = term.element;
|
||||
if (!root) return;
|
||||
const bounds = root.getBoundingClientRect();
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.className = 'xterm-hover pointer-events-none absolute z-50 max-h-[160px] max-w-[360px] overflow-hidden whitespace-pre-wrap break-words rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md';
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
tooltip.textContent = contents;
|
||||
tooltip.style.left = `${Math.max(0, Math.min(event.clientX - bounds.left + 8, bounds.width - 368))}px`;
|
||||
tooltip.style.top = `${Math.max(0, Math.min(event.clientY - bounds.top + 12, bounds.height - 40))}px`;
|
||||
root.appendChild(tooltip);
|
||||
},
|
||||
hide,
|
||||
dispose: hide,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerPluginTerminalLinkProvider(options: {
|
||||
term: XTerm;
|
||||
request: RequestPluginTerminalProviders;
|
||||
canActivate(event: MouseEvent): boolean;
|
||||
openExternal(uri: string): Promise<void>;
|
||||
responseTimeoutMs?: number;
|
||||
isProviderAvailable?(kind: NetcattyTerminalProviderKind): boolean;
|
||||
active?: boolean;
|
||||
visible?: boolean;
|
||||
}): PluginTerminalLinkProviderHost {
|
||||
const tooltip = createTooltip(options.term);
|
||||
let disposed = false;
|
||||
let active = options.active ?? true;
|
||||
let visible = options.visible ?? true;
|
||||
let generation = 0;
|
||||
const controllers = new Set<AbortController>();
|
||||
const invalidate = () => {
|
||||
generation += 1;
|
||||
tooltip.hide();
|
||||
for (const controller of controllers) controller.abort();
|
||||
controllers.clear();
|
||||
};
|
||||
const requestWithTimeout = async (
|
||||
kind: NetcattyTerminalProviderKind,
|
||||
operation: string,
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
supersessionKey: string,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
controllers.add(controller);
|
||||
try {
|
||||
return await waitForPluginTerminalProviderResponse(
|
||||
options.request(kind, operation, payload, 750, supersessionKey, controller.signal),
|
||||
options.responseTimeoutMs ?? PROVIDER_RESPONSE_TIMEOUT_MS,
|
||||
() => controller.abort(),
|
||||
);
|
||||
} finally {
|
||||
controllers.delete(controller);
|
||||
}
|
||||
};
|
||||
const provider: ILinkProvider = {
|
||||
provideLinks(bufferLineNumber, callback) {
|
||||
if (disposed || !active || !visible) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const linkAvailable = options.isProviderAvailable?.('terminal.link') ?? true;
|
||||
const hoverAvailable = options.isProviderAvailable?.('terminal.hover') ?? true;
|
||||
if (!linkAvailable && !hoverAvailable) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const line = lineTextAt(options.term, bufferLineNumber);
|
||||
if (disposed || line == null || line.text.length === 0) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const requestGeneration = generation;
|
||||
void Promise.all([
|
||||
linkAvailable
|
||||
? requestWithTimeout(
|
||||
'terminal.link',
|
||||
'provideLinks',
|
||||
{ line: line.text, bufferLineNumber },
|
||||
`line:${bufferLineNumber}`,
|
||||
)
|
||||
: Promise.resolve({ stale: false, results: Object.freeze([]) }),
|
||||
hoverAvailable
|
||||
? requestWithTimeout(
|
||||
'terminal.hover',
|
||||
'provideHovers',
|
||||
{ line: line.text, bufferLineNumber },
|
||||
`line:${bufferLineNumber}`,
|
||||
)
|
||||
: Promise.resolve({ stale: false, results: Object.freeze([]) }),
|
||||
]).then(([linkResponse, hoverResponse]) => {
|
||||
if (disposed || !active || !visible || requestGeneration !== generation
|
||||
|| linkResponse.stale || hoverResponse.stale) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const currentLine = lineTextAt(options.term, bufferLineNumber);
|
||||
if (currentLine == null || currentLine.text !== line.text) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links = linkResponse.results.flatMap((result) => result.status === 'ok'
|
||||
? normalizePluginLinkResult(result.providerId, result.result, currentLine.text.length)
|
||||
: []);
|
||||
const hovers = hoverResponse.results.flatMap((result) => result.status === 'ok'
|
||||
? normalizePluginHoverResult(result.providerId, result.result, currentLine.text.length)
|
||||
: []);
|
||||
const consumedHoverKeys = new Set<string>();
|
||||
const result: ILink[] = links.flatMap((link) => {
|
||||
const cellRange = pluginTerminalCellRange(currentLine, link.start, link.length);
|
||||
if (!cellRange) return [];
|
||||
const hover = matchingHover(hovers, link.providerId, link.start, link.length);
|
||||
if (hover) consumedHoverKeys.add(`${hover.providerId}\0${hover.start}\0${hover.length}`);
|
||||
const hoverText = [hover?.contents ?? link.label, link.uri].filter(Boolean).join('\n');
|
||||
return [{
|
||||
range: {
|
||||
start: { x: cellRange.x + 1, y: bufferLineNumber },
|
||||
end: { x: cellRange.x + cellRange.width, y: bufferLineNumber },
|
||||
},
|
||||
text: currentLine.text.slice(link.start, link.start + link.length),
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate: (event) => {
|
||||
if (lineTextAt(options.term, bufferLineNumber)?.text === currentLine.text
|
||||
&& options.canActivate(event)) void options.openExternal(link.uri).catch(() => {});
|
||||
},
|
||||
...(hoverText ? {
|
||||
hover: (event: MouseEvent) => {
|
||||
if (lineTextAt(options.term, bufferLineNumber)?.text === currentLine.text) {
|
||||
tooltip.show(event, hoverText);
|
||||
} else {
|
||||
tooltip.hide();
|
||||
}
|
||||
},
|
||||
leave: () => tooltip.hide(),
|
||||
} : {}),
|
||||
}];
|
||||
});
|
||||
for (const hover of hovers) {
|
||||
if (consumedHoverKeys.has(`${hover.providerId}\0${hover.start}\0${hover.length}`)) continue;
|
||||
const cellRange = pluginTerminalCellRange(currentLine, hover.start, hover.length);
|
||||
if (!cellRange) continue;
|
||||
result.push({
|
||||
range: {
|
||||
start: { x: cellRange.x + 1, y: bufferLineNumber },
|
||||
end: { x: cellRange.x + cellRange.width, y: bufferLineNumber },
|
||||
},
|
||||
text: currentLine.text.slice(hover.start, hover.start + hover.length),
|
||||
decorations: { pointerCursor: false, underline: false },
|
||||
activate: () => {},
|
||||
hover: (event: MouseEvent) => {
|
||||
if (lineTextAt(options.term, bufferLineNumber)?.text === currentLine.text) {
|
||||
tooltip.show(event, hover.contents);
|
||||
} else {
|
||||
tooltip.hide();
|
||||
}
|
||||
},
|
||||
leave: () => tooltip.hide(),
|
||||
});
|
||||
}
|
||||
callback(result.length > 0 ? result : undefined);
|
||||
}).catch(() => callback(undefined));
|
||||
},
|
||||
};
|
||||
const registration = options.term.registerLinkProvider(provider);
|
||||
return {
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
active = nextActive;
|
||||
invalidate();
|
||||
},
|
||||
setVisible(nextVisible) {
|
||||
if (visible === nextVisible) return;
|
||||
visible = nextVisible;
|
||||
invalidate();
|
||||
},
|
||||
providerAvailabilityChanged() {
|
||||
invalidate();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
invalidate();
|
||||
tooltip.dispose();
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user