diff --git a/docs/images/video-preview-desktop.png b/docs/images/video-preview-desktop.png new file mode 100644 index 000000000..63421d624 Binary files /dev/null and b/docs/images/video-preview-desktop.png differ diff --git a/docs/images/video-preview-mobile.png b/docs/images/video-preview-mobile.png new file mode 100644 index 000000000..91bf0675c Binary files /dev/null and b/docs/images/video-preview-mobile.png differ diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 7487b24c2..cd27483a8 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -831,7 +831,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba const workerSrc = "; worker-src 'self' blob:"; const csp = `default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` + - `img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`; + `img-src 'self' data: blob:; media-src 'self' blob: https: http:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`; app.addHook('onRequest', (req, reply, done) => { reply.header('X-Content-Type-Options', 'nosniff'); diff --git a/src/web/public/app.js b/src/web/public/app.js index bc1abc2a1..0a26226bc 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3645,6 +3645,12 @@ class CodemanApp { return; } + if (urlLink && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey && !ev.altKey && this.openVideoUrlPreview?.(urlLink.href, body.dataset.rvSessionId || this.activeSessionId)) { + ev.preventDefault(); + ev.stopPropagation(); + return; + } + // One-click copy: lift the raw source from the sibling
, or —
// COD-263 — the rendered text of the sibling .
const copyBtn = ev.target.closest('.rv-copy-btn');
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 5e32a7a69..a5ee17017 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -5262,6 +5262,70 @@ Object.assign(CodemanApp.prototype, {
}
},
+ /** Direct media loading stays in the browser; external URLs never become server fetches. */
+ openVideoUrlPreview(value, sessionId = this.activeSessionId) {
+ if (typeof value !== 'string' || !/^https?:\/\//i.test(value)) return false;
+ let url;
+ try {
+ url = new URL(value);
+ if (url.username || url.password || !/\.(mp4|webm|mov|m4v|ogv)$/i.test(url.pathname)) return false;
+ } catch { return false; }
+ const overlay = this.$('filePreviewOverlay');
+ const body = this.$('filePreviewBody');
+ if (!overlay || !body) return false;
+ if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return true;
+
+ this._stopFilePreviewPolling();
+ this._filePreviewLoad = (this._filePreviewLoad || 0) + 1;
+ this._stopFilePreviewMedia();
+ this._resetFilePreviewEdit?.();
+ this._resetFilePreviewExternalAction?.();
+ this._resetFilePreviewOriginalDownloadAction?.();
+ this._teardownOfficePreviewResources?.();
+ this._teardownOutlineScrollSpy?.();
+ this._closeAnnotationPopover?.();
+ this._closeAnnotationTray?.();
+ this._hideFilePreviewNavigation();
+ this._updateFolderPreviewNav?.(null);
+ if (this.folderGrid) this.folderGrid.index = null;
+ this.filePreviewText = null;
+ this.filePreviewContent = '';
+ this.filePreviewPage = null;
+ this.filePreviewDetachUrl = '';
+ this._annotations = [];
+ this._updateFilePreviewToolbar?.('none');
+ const detach = this.$('filePreviewDetachBtn');
+ if (detach) detach.hidden = true;
+ const folder = this.$('filePreviewFolderBtn');
+ if (folder) folder.hidden = true;
+ const state = this.filePreviewState = { sessionId, filePath: url.href, previewKind: 'video', externalPreviewUrl: url.href };
+ this._recordFilePreviewHistory(url.href, sessionId, null, {});
+ this._bindFilePreviewChrome();
+ this._restorePreviewSize();
+ this._applyFilePreviewExpanded();
+ overlay.classList.add('visible');
+ let name = url.pathname.split('/').pop();
+ try { name = decodeURIComponent(name); } catch { /* Keep an undecodable file name readable. */ }
+ this.$('filePreviewTitle').textContent = name;
+ const footer = this.$('filePreviewFooter');
+ footer.textContent = `Video • ${url.host}`;
+ const external = this.$('filePreviewOpenNewTab');
+ if (external) external.disabled = false;
+ const video = document.createElement('video');
+ video.controls = true;
+ video.autoplay = true;
+ video.playsInline = true;
+ video.preload = 'metadata';
+ video.style.width = '100%';
+ video.setAttribute('aria-label', name);
+ video.addEventListener('error', () => {
+ if (this.filePreviewState === state) footer.textContent = 'Video could not play. Try “Open in new tab” in the toolbar.';
+ });
+ video.src = url.href;
+ body.replaceChildren(video);
+ return true;
+ },
+
/**
* @param {object} [options]
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
@@ -5269,6 +5333,7 @@ Object.assign(CodemanApp.prototype, {
* paging through 300 renders never touches the attachment registry.
*/
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
+ if (!attachmentId && this.openVideoUrlPreview?.(filePath, sessionId)) return;
if (!sessionId || !filePath) return;
const folderFile = options.folderFile || null;
// Prev/next only while browsing a folder grid; a preview opened from anywhere
diff --git a/src/web/public/sw.js b/src/web/public/sw.js
index dffb03fd5..a123134ea 100644
--- a/src/web/public/sw.js
+++ b/src/web/public/sw.js
@@ -100,6 +100,9 @@ self.addEventListener('fetch', (event) => {
if (request.method !== 'GET') return;
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
+ // Let the browser stream and seek media directly. Cloning/caching a video
+ // duplicates large downloads, and Cache.put cannot store partial (206) responses.
+ if (request.destination === 'video' || request.destination === 'audio' || request.headers.get('range')) return;
if (request.url.includes('/api/')) return;
// A rendered HTML page is served `no-store` behind a capability that logout
// revokes; caching a popped-out page here would let the offline fallback
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index d5aa45e4d..635d9b53e 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -613,7 +613,7 @@ function _debugEchoLogOuter() {
} catch {}
}
-const TERMINAL_FILE_URI_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'docx', 'pptx', 'xlsx', 'md', 'txt']);
+const TERMINAL_FILE_URI_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'docx', 'pptx', 'xlsx', 'md', 'txt', 'mp4', 'webm', 'mov', 'm4v', 'ogv']);
Object.assign(CodemanApp.prototype, {
@@ -2556,6 +2556,11 @@ Object.assign(CodemanApp.prototype, {
'docx',
'pptx',
'xlsx',
+ 'mp4',
+ 'webm',
+ 'mov',
+ 'm4v',
+ 'ogv',
'json',
'yaml',
'yml',
@@ -2576,6 +2581,11 @@ Object.assign(CodemanApp.prototype, {
'docx',
'pptx',
'xlsx',
+ 'mp4',
+ 'webm',
+ 'mov',
+ 'm4v',
+ 'ogv',
'md',
'txt',
]);
@@ -3662,6 +3672,8 @@ Object.assign(CodemanApp.prototype, {
return {
activate: (_event, text) => {
if (/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) {
+ const proxied = window.CodemanWebviewLinks?.linkNeedsWebTabProxy?.(text, window.location?.hostname);
+ if (!proxied && this.openVideoUrlPreview?.(text, getSessionId())) return;
if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text);
return;
}
@@ -3679,9 +3691,10 @@ Object.assign(CodemanApp.prototype, {
_openTerminalUrl(text) {
// A `localhost` link tapped from another device can only work through the
// server: route it into a proxied web tab (webview-tabs.js). Anything else
- // opens as before. Lives here rather than in the link provider's activate()
+ // uses the video preview or opens normally. Lives here rather than in the link provider's activate()
// so every caller of this one opener gets the same routing.
if (this.openLinkThroughWebTabIfLoopback?.(text)) return;
+ if (this.openVideoUrlPreview?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
@@ -3792,18 +3805,25 @@ Object.assign(CodemanApp.prototype, {
const bufferLineNumber = (buffer.viewportY || 0) + cell.row;
let activated = false;
+ // xterm's native OSC 8 provider owns labelled links, whose visible text
+ // need not contain the URL. Its normal mouse path never runs on touch.
+ const nativeProvider = this.terminal?._core?._linkProviderService?.linkProviders?.[0];
+ const providers = nativeProvider && nativeProvider !== provider ? [nativeProvider, provider] : [provider];
try {
- provider.provideLinks(bufferLineNumber, (links) => {
- const link = links?.find((candidate) => {
- const { start, end } = candidate.range;
- const afterStart = bufferLineNumber > start.y || (bufferLineNumber === start.y && cell.col >= start.x);
- const beforeEnd = bufferLineNumber < end.y || (bufferLineNumber === end.y && cell.col <= end.x);
- return afterStart && beforeEnd;
+ for (const candidateProvider of providers) {
+ if (activated) break;
+ candidateProvider.provideLinks(bufferLineNumber, (links) => {
+ const link = links?.find((candidate) => {
+ const { start, end } = candidate.range;
+ const afterStart = bufferLineNumber > start.y || (bufferLineNumber === start.y && cell.col >= start.x);
+ const beforeEnd = bufferLineNumber < end.y || (bufferLineNumber === end.y && cell.col <= end.x);
+ return afterStart && beforeEnd;
+ });
+ if (!link || typeof link.activate !== 'function') return;
+ link.activate(event, link.text);
+ activated = true;
});
- if (!link || typeof link.activate !== 'function') return;
- link.activate(event, link.text);
- activated = true;
- });
+ }
} catch (err) {
console.warn('[LinkProvider] tap activation failed:', err);
return false;
diff --git a/test/fixtures/media/preview.mp4 b/test/fixtures/media/preview.mp4
new file mode 100644
index 000000000..785d9b01d
Binary files /dev/null and b/test/fixtures/media/preview.mp4 differ
diff --git a/test/routes/file-uri-preview.test.ts b/test/routes/file-uri-preview.test.ts
index 0c1d5a98e..a392ae6d4 100644
--- a/test/routes/file-uri-preview.test.ts
+++ b/test/routes/file-uri-preview.test.ts
@@ -143,6 +143,23 @@ describe('POST /api/sessions/:id/file-uri-preview', () => {
});
}
+ it('registers a generated MP4 and streams seekable video bytes through its guarded attachment route', async () => {
+ const filePath = join(workspaceDir, 'preview.mp4');
+ await fs.writeFile(filePath, 'video-bytes');
+ const registered = await postUri(pathToFileURL(filePath).href);
+ expect(registered.statusCode).toBe(200);
+ const id = registered.json().data.attachmentId;
+ const response = await app.inject({
+ method: 'GET',
+ url: `/api/sessions/${ctx._sessionId}/attachments/${id}/raw`,
+ headers: { range: 'bytes=0-3' },
+ });
+ expect(response.statusCode).toBe(206);
+ expect(response.headers['content-type']).toContain('video/mp4');
+ expect(response.headers['content-range']).toBe('bytes 0-3/11');
+ expect(response.body).toBe('vide');
+ });
+
it('resolves a percent-encoded local artifact to sanitized opaque attachment metadata', async () => {
const filePath = join(workspaceDir, 'mockup one.png');
await fs.writeFile(filePath, 'image-bytes');
diff --git a/test/sw-fetch-skip.test.ts b/test/sw-fetch-skip.test.ts
index 2280960a3..515893b60 100644
--- a/test/sw-fetch-skip.test.ts
+++ b/test/sw-fetch-skip.test.ts
@@ -33,7 +33,11 @@ function loadFetchListener(): (event: unknown) => void {
function fetchEvent(path: string) {
return {
- request: { method: 'GET', url: `http://localhost${path}`, headers: { get: () => null } },
+ request: {
+ method: 'GET',
+ url: `http://localhost${path}`,
+ headers: { get: (_name: string): string | null => null },
+ },
respondWith: vi.fn(),
};
}
@@ -48,6 +52,22 @@ describe('service worker fetch skip list', () => {
}
});
+ it.each(['video', 'audio'])('does not cache %s streams', (destination) => {
+ const onFetch = loadFetchListener();
+ const event = fetchEvent('/preview.mp4?token=private');
+ Object.assign(event.request, { destination });
+ onFetch(event);
+ expect(event.respondWith).not.toHaveBeenCalled();
+ });
+
+ it('leaves range requests to the native network path even without a media destination', () => {
+ const onFetch = loadFetchListener();
+ const event = fetchEvent('/download');
+ event.request.headers.get = (name: string) => (name === 'range' ? 'bytes=0-1023' : null);
+ onFetch(event);
+ expect(event.respondWith).not.toHaveBeenCalled();
+ });
+
it('still answers an app asset', () => {
const onFetch = loadFetchListener();
const event = fetchEvent('/app.js');
diff --git a/test/terminal-document-link-provider.test.ts b/test/terminal-document-link-provider.test.ts
index f7193bdd0..0e70115c4 100644
--- a/test/terminal-document-link-provider.test.ts
+++ b/test/terminal-document-link-provider.test.ts
@@ -1263,3 +1263,15 @@ it('routes native Codex file hyperlinks through the guarded preview using the ow
handler.activate(null, uri);
expect(preview).toHaveBeenCalledTimes(1);
});
+
+it.each(['mp4', 'webm', 'mov', 'm4v', 'ogv'])(
+ 'routes generated %s file URI labels to the guarded video preview',
+ (extension) => {
+ const { proto } = loadTerminalUiPrototype();
+ const host = { sessions: new Map([['video-pane', { mode: 'codex' }]]), openFileUriPreview: vi.fn() };
+ const handler = proto._terminalOsc8LinkHandler.call(host, { getSessionId: () => 'video-pane' });
+ const uri = `file:///Users/example/output/preview.${extension}`;
+ handler.activate(null, uri);
+ expect(host.openFileUriPreview).toHaveBeenCalledExactlyOnceWith(uri, 'video-pane');
+ }
+);
diff --git a/test/video-url-preview.browser.test.ts b/test/video-url-preview.browser.test.ts
new file mode 100644
index 000000000..fa474e22b
--- /dev/null
+++ b/test/video-url-preview.browser.test.ts
@@ -0,0 +1,155 @@
+import { resolve } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+const clipPath = resolve(import.meta.dirname, 'fixtures/media/preview.mp4');
+const videoUrl = 'https://media.example.test/generated%20preview.MP4?token=a%2Bb&download=0#t=0.2';
+let server: WebServer;
+let browser: Browser;
+let base: string;
+
+async function openPage(mobile = false): Promise {
+ const page = await browser.newPage({
+ viewport: mobile ? { width: 393, height: 852 } : { width: 1280, height: 850 },
+ isMobile: mobile,
+ hasTouch: mobile,
+ });
+ await page.route('https://media.example.test/**', async (route) => {
+ if (route.request().url().includes('broken.mp4')) {
+ await route.fulfill({ status: 404, body: 'Not found' });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'video/mp4',
+ path: clipPath,
+ headers: { 'Accept-Ranges': 'bytes' },
+ });
+ });
+ await page.goto(base, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.xterm', { state: 'attached' });
+ await page.waitForTimeout(3000);
+ await page.waitForFunction(() => !!navigator.serviceWorker.controller);
+ return page;
+}
+
+async function expectPlaying(page: Page) {
+ await page.waitForFunction(
+ () => {
+ const video = document.querySelector('#filePreviewBody video');
+ return video && (video.error || (video.readyState >= 1 && video.duration > 0));
+ },
+ undefined,
+ { timeout: 10000 }
+ );
+ const error = await page
+ .locator('#filePreviewBody video')
+ .evaluate((el: HTMLVideoElement) => el.error?.message || null);
+ expect(error).toBeNull();
+ await page.evaluate(`document.querySelector('#filePreviewBody video').play()`);
+ await page.waitForFunction(() => document.querySelector('#filePreviewBody video')!.currentTime > 0);
+}
+
+describe('linked video previews', () => {
+ beforeAll(async () => {
+ server = new WebServer(0, false, true);
+ await server.start();
+ base = `http://127.0.0.1:${server.boundPort}`;
+ browser = await chromium.launch({ headless: true });
+ });
+ afterAll(async () => {
+ await browser?.close();
+ await server?.stop();
+ });
+
+ for (const { mobile, labelled } of [
+ { mobile: false, labelled: false },
+ { mobile: true, labelled: false },
+ { mobile: false, labelled: true },
+ { mobile: true, labelled: true },
+ ]) {
+ it(`plays a signed ${labelled ? 'labelled' : 'plain'} video link on ${mobile ? 'mobile' : 'desktop'} and unloads on close`, async () => {
+ const page = await openPage(mobile);
+ const popups: Page[] = [];
+ page.on('popup', (popup) => popups.push(popup));
+ try {
+ await page.evaluate('window.__videoTestApp = app');
+ const renderedLink = labelled ? `\x1b]8;;${videoUrl}\x07Watch preview\x1b]8;;\x07` : videoUrl;
+ const point = await page.evaluate(async (url) => {
+ // Exercise the terminal's real link provider and touch/click path.
+ const app = (window as any).__videoTestApp;
+ app.hideWelcome();
+ app.terminal.reset();
+ await new Promise((resolve) => app.terminal.write(url + '\r\n', resolve));
+ const rect = app.terminal.element.querySelector('.xterm-screen').getBoundingClientRect();
+ const cell = app.terminal._core._renderService.dimensions.css.cell;
+ return { x: rect.left + cell.width * 8.5, y: rect.top + cell.height * 0.5 };
+ }, renderedLink);
+ if (mobile) await page.touchscreen.tap(point.x, point.y);
+ else {
+ await page.mouse.move(point.x, point.y);
+ await page.waitForTimeout(150);
+ await page.mouse.click(point.x, point.y);
+ }
+ await page.waitForSelector('#filePreviewOverlay.visible');
+ expect(await page.locator('#filePreviewOverlay').isVisible()).toBe(true);
+ expect(await page.locator('#filePreviewTitle').textContent()).toBe('generated preview.MP4');
+ expect(await page.locator('#filePreviewBody video').getAttribute('src')).toBe(videoUrl);
+ expect(await page.locator('#filePreviewBody video').getAttribute('controls')).not.toBeNull();
+ expect(await page.locator('#filePreviewBody video').getAttribute('playsinline')).not.toBeNull();
+ await expectPlaying(page);
+ await page.evaluate(`document.querySelector('#filePreviewBody video').currentTime = 1`);
+ await page.waitForFunction(
+ () => document.querySelector('#filePreviewBody video')!.currentTime >= 1
+ );
+ const viewport = page.viewportSize()!;
+ const box = await page.locator('#filePreviewBody video').boundingBox();
+ expect(box!.width).toBeLessThanOrEqual(viewport.width);
+ expect(box!.height).toBeLessThanOrEqual(viewport.height);
+ expect(popups).toHaveLength(0);
+ await page.screenshot({ path: `/tmp/codeman-video-preview-${mobile ? 'mobile' : 'desktop'}.png` });
+ await page.evaluate(`window.__oldVideo = document.querySelector('#filePreviewBody video')`);
+ await page.keyboard.press('Escape');
+ expect(await page.locator('#filePreviewOverlay').isVisible()).toBe(false);
+ expect(await page.evaluate(`window.__oldVideo.paused && !window.__oldVideo.getAttribute('src')`)).toBe(true);
+ } finally {
+ await page.close();
+ }
+ });
+ }
+
+ it('opens a response link on a real click, stops the prior video on replacement, and offers error fallback', async () => {
+ const page = await openPage();
+ try {
+ await page.evaluate(`(() => {
+ const body = document.createElement('div'); body.id='video-link-test';
+ body.innerHTML='Watch preview';
+ document.body.append(body); app._bindResponseViewerInteractions(body);
+ })()`);
+ await page.locator('#video-link-test a').click();
+ await expectPlaying(page);
+ await page.evaluate(
+ `window.__oldVideo = document.querySelector('#filePreviewBody video'); app._openTerminalUrl('https://media.example.test/broken.mp4')`
+ );
+ expect(await page.evaluate(`window.__oldVideo.paused && !window.__oldVideo.getAttribute('src')`)).toBe(true);
+ await page.waitForFunction(() =>
+ document.getElementById('filePreviewFooter')!.textContent!.includes('could not play')
+ );
+ expect(await page.locator('#filePreviewOpenNewTab').isEnabled()).toBe(true);
+ expect(await page.evaluate(`app.filePreviewState.externalPreviewUrl`)).toBe(
+ 'https://media.example.test/broken.mp4'
+ );
+ } finally {
+ await page.close();
+ }
+ });
+
+ it('permits external media in CSP while retaining script and frame restrictions', async () => {
+ const response = await fetch(base);
+ const csp = response.headers.get('content-security-policy')!;
+ expect(csp).toContain("media-src 'self' blob: https: http:");
+ expect(csp).toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;");
+ expect(csp).toContain("frame-ancestors 'self'");
+ });
+});
diff --git a/test/video-url-preview.test.ts b/test/video-url-preview.test.ts
new file mode 100644
index 000000000..f1293e0a8
--- /dev/null
+++ b/test/video-url-preview.test.ts
@@ -0,0 +1,80 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import ts from 'typescript';
+import { describe, expect, it, vi } from 'vitest';
+
+function method(file: string, name: string) {
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8');
+ const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
+ let result = '';
+ function visit(node: ts.Node) {
+ if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) result = node.getText(tree);
+ ts.forEachChild(node, visit);
+ }
+ visit(tree);
+ if (!result) throw new Error(`Missing ${name}`);
+ return result;
+}
+
+describe('direct video link routing', () => {
+ it.each([
+ 'javascript:alert(1)',
+ 'data:video/mp4;base64,AAAA',
+ 'file:///tmp/movie.mp4',
+ 'ftp://example.test/movie.mp4',
+ 'https://user:password@example.test/movie.mp4',
+ 'https://example.test/page?file=movie.mp4',
+ 'https://example.test/movie.mp4.html',
+ 'https://example.test/movie',
+ 'https://',
+ null,
+ '/workspace/movie.mp4',
+ ])('does not embed an unsupported or credential-bearing URL: %s', (url) => {
+ const open = vm.runInNewContext(`({${method('panels-ui.js', 'openVideoUrlPreview')}})`, { URL });
+ const host = { $: vi.fn(), activeSessionId: null };
+ expect(open.openVideoUrlPreview.call(host, url)).toBe(false);
+ expect(host.$).not.toHaveBeenCalled();
+ });
+
+ it('consumes a video link without opening a new browser tab', () => {
+ const external = vi.fn();
+ const host = {
+ openLinkThroughWebTabIfLoopback: vi.fn(() => false),
+ openVideoUrlPreview: vi.fn(() => true),
+ };
+ const opener = vm.runInNewContext(`({${method('terminal-ui.js', '_openTerminalUrl')}})`, {
+ window: { open: external },
+ });
+ opener._openTerminalUrl.call(host, 'https://example.test/movie.mp4');
+ expect(host.openVideoUrlPreview).toHaveBeenCalledWith('https://example.test/movie.mp4');
+ expect(external).not.toHaveBeenCalled();
+ });
+
+ it('opens a labelled OSC 8 video directly with the originating session and no external-link prompt', () => {
+ const confirm = vi.fn();
+ const host = { openVideoUrlPreview: vi.fn(() => true), _openTerminalUrl: vi.fn() };
+ const factory = vm.runInNewContext(`({${method('terminal-ui.js', '_terminalOsc8LinkHandler')}})`, {
+ window: { confirm },
+ });
+ factory._terminalOsc8LinkHandler
+ .call(host, { getSessionId: () => 'source-session' })
+ .activate({}, 'https://example.test/movie.mp4');
+ expect(host.openVideoUrlPreview).toHaveBeenCalledWith('https://example.test/movie.mp4', 'source-session');
+ expect(confirm).not.toHaveBeenCalled();
+ expect(host._openTerminalUrl).not.toHaveBeenCalled();
+ });
+
+ it('preserves the loopback proxy and ordinary external-link behavior', () => {
+ const external = vi.fn();
+ const host = { openLinkThroughWebTabIfLoopback: vi.fn(() => true), openVideoUrlPreview: vi.fn(() => false) };
+ const opener = vm.runInNewContext(`({${method('terminal-ui.js', '_openTerminalUrl')}})`, {
+ window: { open: external },
+ });
+ opener._openTerminalUrl.call(host, 'http://localhost:8080/movie.mp4');
+ expect(host.openVideoUrlPreview).not.toHaveBeenCalled();
+ host.openLinkThroughWebTabIfLoopback.mockReturnValue(false);
+ opener._openTerminalUrl.call(host, 'https://example.test/docs');
+ expect(external).toHaveBeenCalledWith('https://example.test/docs', '_blank', 'noopener,noreferrer');
+ });
+});