在 Claude Code 終端預覽 Markdown、圖片、影片與送出前的貼圖;自訂 API 或 herdr 需另設環境變數,見 README

在 Claude Code 終端內預覽 Markdown、圖片、影片,以及尚未送出的貼圖。Plugin 名稱是 tui-preview-mod;claude-code-preview-mod 是專案名稱。
本專案以 Claude Code 2.1.289 測試(0.1.2 以前為 2.1.288)。已實測 Markdown 排版、Ghostty 圖片像素(本機、herdr 0.9.3 內、從 Air 經 SSH)、連續影片影格、播放控制、送出前貼圖預覽列,以及點選回覆中的圖片路徑開啟預覽。mosh 只同步文字畫面,看不到圖片像素;Air 剪貼簿傳到遠端與其他 Claude Code 版本尚未驗證。 詳見 相容性 與 驗證紀錄。
需要可使用 Mods 的 Claude Code、互動式終端,以及執行 Claude 的主機上已安裝的 Node.js 20+、ffmpeg、ffprobe。Node 必須在 Claude 程序的 PATH;媒體工具在 /opt/homebrew/bin、/usr/local/bin 或 /usr/bin 查找。測試環境為 Node 26.8.1、ffmpeg/ffprobe 9.0.2,並未驗證所有 Node/codec 版本。
使用自訂 ANTHROPIC_BASE_URL,或在 herdr 內執行 Claude 時,安裝後還要設定環境變數;否則 /preview 可能不會出現、圖片空白,回覆中的路徑也無法點選。做法與代價見自訂 API 與 herdr。
執行不需要 Yarn、node_modules、MCP server、瀏覽器或 HTTP port。先閱讀 權限與資料邊界,再從你已取得並信任的專案目錄載入:
程式碼公開放在 andrew54068/claude-plugins 的 tui-preview-mod/,使用既有 andrew54068 市集,不需要私人 repo 存取權。安裝與 Node/ffmpeg/ffprobe 必須在執行 Claude 的主機完成;Air 透過 SSH 使用 Pro 的 Claude 時,是 Pro 需要這些工具。官方 marketplace 說明
在 Claude 裡依序執行:
/plugin marketplace add andrew54068/claude-plugins
/plugin install tui-preview-mod@andrew54068
/reload-plugins
/preview README.md
如果 andrew54068 已註冊,不必重複加入來源;先在 shell 執行 claude plugin marketplace update andrew54068 再安裝即可。
若先前安裝過 tui-preview-mod@preview-mods,先在 /plugin 的 Installed → 舊外掛 → Configure 記下已儲存的 roots 與 autoPreview。再卸載舊外掛,避免同名 Mod 同時載入;卸載會移除舊外掛保存的 options,不影響其他外掛或市集:
/plugin uninstall tui-preview-mod@preview-mods
再安裝 tui-preview-mod@andrew54068,並從 Configure 重新套用先前記下的設定;尤其是曾設為 false 的 autoPreview,否則會恢復預設 true。andrew54068 是 marketplace 名稱;tui-preview-mod 是 plugin 名稱;本版為 0.1.3。舊 preview-mods 來源可保留;切換來源不是用來繞過 Mod 開關。
GitHub 安裝只改變取得外掛的方式,不能保證 /preview 一定可用。使用自訂 API 時,最常見的原因是 Mods 開關沿用磁碟快取裡的舊值,先依自訂 API 與 herdr設定 DISABLE_TELEMETRY=1。若仍缺少指令,執行 /plugin 檢查 mods active,並在 shell 執行 claude plugin test 查看載入限制;組織政策或 Anthropic 的 Mod 開關仍可拒絕它。官方診斷
claude --version
node --version
ffmpeg -version
ffprobe -version
claude plugin validate --strict /absolute/path/to/claude-plugins/tui-preview-mod/.claude-plugin/plugin.json
claude plugin validate /absolute/path/to/claude-plugins/.claude-plugin/marketplace.json
claude --plugin-dir /absolute/path/to/claude-plugins/tui-preview-mod
這種載入只作用於該次 Claude session,沒有安裝到全域。進入後用 /plugin 檢查 tui-preview-mod 是否載入。組織政策或 rollout 狀態仍可能阻擋 Mods;看到拒絕訊息時不能把它當成成功,也不要繞過政策。官方 Mods 說明
plugin manifest 在 tui-preview-mod/.claude-plugin/;marketplace manifest 在 repository 根目錄的 .claude-plugin/。請分別指定檔案;只驗證 repository 目錄會選到 marketplace,漏掉 Mod 的 hooks/calls。原市集有既有 metadata warnings,不能把它們與新外掛的 strict validation 混為一談。
| 命令 | 效果 |
|---|---|
/preview notes.md | 以原生 Markdown 分頁 |
/preview images/photo with spaces.jpg | 顯示圖片;路徑中的空白保留 |
/preview "videos/demo clip.mp4" | 以真實連續影格播放影片,無聲音 |
單擊回覆中的 content/a.png | 開啟同一個預覽 pane;需全螢幕介面,見下方說明 |
/preview pasted | 開啟目前 session 最近已完成的貼圖預覽 |
/preview close | 關閉 pane 並停止其解碼程序 |
/preview on、/preview off | 切換本次載入的自動貼圖/Read 圖片預覽 |
/preview | 顯示操作說明 |
路徑預設限制在目前 session.root()。額外目錄需在 /plugin 的 tui-preview-mod 設定中明確加入 roots 絕對路徑;最多採用 32 個。autoPreview 設定控制重新載入時的預設,on/off 不會儲存這項設定。off 不妨礙明確的路徑預覽。
影片按 p 播放/暫停、h 往前 5 秒、l 往後 5 秒、r 重播、x 或 Esc 關閉。Markdown 用 h/l 或按鈕換頁。先讓 pane 取得鍵盤焦點;原生 Ctrl-X Tab 可切換焦點。
全螢幕介面(CLAUDE_CODE_NO_FLICKER=1 或 /tui fullscreen)下,Claude 回覆裡的圖片或影片路徑會畫成連結,單擊就開啟預覽 pane。相對路徑以 session.root() 為準,絕對路徑須在允許的 roots 內。
vscode://、mailto:、Email 等),整則照原生繪製。/preview <path> 相同的目錄、大小與格式檢查。TERM_PROGRAM=herdr)不在其中,回覆照原生繪製;herdr 設定 FORCE_HYPERLINK=1 後才會變成可點選的路徑,見自訂 API 與 herdr。/plugin 的設定把 clickablePaths 設為 false。原生貼圖進入目前 composer 後,約每 500ms 更新預覽列;最多兩張縮圖,其餘透過圖片按鈕明確載入。Read inline 與 composer 共用兩個自動解碼名額,前景 pane 是另外一個明確操作。Mod 不改寫輸入、不提交 prompt、不改變 Read 傳給模型的內容。貼圖若尚未完成、讀取失敗或自動預覽關閉,/preview pasted 可能沒有可用快照;可透過圖片按鈕重試。實作
| 項目 | 上限/行為 |
|---|---|
Markdown:.md、.markdown | UTF-8,2 MiB;每頁最多 9000 字元單位,移除終端控制字元 |
| 圖片:PNG、JPEG、WebP、GIF | 來源 32 MiB;GIF 只取第一幀 |
| 影片:MP4、MOV、WebM、MKV | 來源 2 GiB;是否可解碼仍取決於已安裝 ffmpeg |
| 所有圖片/影片輸出 | PNG,最大 640×360,每幀最多 2 MiB |
| 影片播放 | 8 fps、無聲音,只預覽來源前 600 秒,跳轉也受此範圍限制 |
不支援 URL、裝置、FIFO、越界檔案或檔案 symlink。PNG bytes 交給原生 Image/ui.blit;終端無法畫像素時可顯示原生替代文字。這不是獨立播放器,亦不提供無遠端 Claude 的 SSH 檔案瀏覽器。helper
settings 使用自訂 ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,或在 herdr 內執行 Claude 時,用這個指令啟動:
claude --settings '{"env":{"DISABLE_TELEMETRY":"1","CLAUDE_CODE_FORCE_TERMINAL_IMAGES":"1","FORCE_HYPERLINK":"1"}}'
| 設定 | 原因 | 代價 |
|---|---|---|
DISABLE_TELEMETRY=1 | 自訂 API 時,Claude Code 不會向旗標服務取得本程序的 Mods 開關,而是沿用其他程序寫入 ~/.claude.json 的舊值;舊值為關閉時 /preview 不會出現 | 該 session 的實驗功能都改用內建預設(例如不啟用 Artifact 工具),也不送遙測 |
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 | herdr 回報的終端名稱是 libghostty,Claude Code 不認得就停用圖片 | 終端不支援 Kitty 圖片時畫出空白 |
FORCE_HYPERLINK=1 | 讓回覆中的路徑成為可點選連結 | 所有連結都改用終端超連結輸出 |
herdr 需 0.9.3 以上;0.8.x 即使設定 kitty_graphics = true,直接送出的 Kitty 圖片也是空白。直接在 Ghostty 執行時,只有自訂 API 需要第一項。
不想每次加 --settings,可把第一項放進 ~/.claude/settings.json 的 env,後兩項只在 herdr 內 export(herdr 的 pane 有 HERDR_ENV):
if [[ -n $HERDR_ENV ]]; then
export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 FORCE_HYPERLINK=1
fi
用 cc-switch 這類會重寫 settings.json 的工具時,env 與 enabledPlugins 也要寫進它保存的設定,否則切換供應商後會消失。
在檔案所在的遠端主機執行 Claude Code + 此 Mod + Node/ffmpeg/ffprobe,再由 client 終端觀看。使用現有可信 SSH 連線即可,不需為 Mod 開 port:
ssh your-trusted-host
claude --plugin-dir /absolute/path/on/remote/claude-plugins/tui-preview-mod
從 Air 經 SSH 連到 Pro 的 herdr,用上方指令啟動後,Air 的 Ghostty 已畫出圖片像素。client 剪貼簿不會因 SSH 自動變成 server 的剪貼簿:先讓圖片真正進入遠端 Claude composer,才有可預覽的附件。
mosh 只同步文字畫面,不轉送 Kitty 圖片協定,所以經 mosh 看不到像素;pane、按鍵與點選仍可用。需要從 Air 看到像素時請改用 SSH。
在 shell 更新 GitHub catalog 與外掛,然後重新啟動 Claude 套用更新:
claude plugin marketplace update andrew54068
claude plugin update tui-preview-mod@andrew54068
互動式 /plugin 沒有 update 子命令;也可從 Installed 頁面的 Update now 更新,不要使用 /plugin update。
只移除外掛,保留 marketplace:/plugin uninstall tui-preview-mod@andrew54068。不要為了移除此 Mod 而刪除整個 andrew54068 市集,那會影響其餘外掛。 shell 的等效命令去掉前面的 /,並在 plugin 前加 claude;若以非預設 scope 安裝,移除時指定相同 --scope。
只用 --plugin-dir 試用者,下次不帶該參數即可。官方 marketplace 說明
開發用 Yarn Classic;yarn run check 只驗證 Node/helper core。原生 Mod 另需 claude plugin test . 與 runtime 產生型別後的 Mod typecheck,完整命令見 CONTRIBUTING。
MIT License,見 LICENSE。
hooks/register.ts 447 lines1import type { CoreEngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderInput, RenderElement, Timer, PluginOptions } from 'claude-code';
2import { NdjsonParser } from './protocol.ts';
3import type { HeaderRecord, FrameRecord, MarkdownRecord } from './protocol.ts';
4import { Player } from './player.ts';
5import { hyperlinkTerminal, linkifyMediaPaths, mentionsMedia, pathFromHref } from './links.ts';
6
7type Api = CoreEngineInterface;
8type Job = { stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>; stopped: boolean; stopping?: Promise<void> };
9type View = {
10 header?: HeaderRecord; frame?: FrameRecord; pages: MarkdownRecord[]; page: number;
11 loading: boolean; error: string; source?: string[]; player?: Player; job?: Job;
12 mount?: { requestId: string; columns: number; rows: number }; generation: number; automaticPending?: boolean; displayedSecond?: number;
13};
14type Attachment = { id: number; key: string; root: string; sessionId: string; view: View };
15type AutomaticTask = { target: View; args: string[]; current: () => boolean; input?: string };
16const view = (): View => ({ pages: [], page: 0, loading: true, error: '', generation: 0 });
17const message = (code: string) => ({ DEPENDENCY: '需要 ffmpeg 與 ffprobe,請先安裝後再預覽。', ROOT: '檔案不在允許的預覽目錄內。', LIMIT: '檔案或影格超過預覽上限。', FORMAT: '不支援這個檔案格式。', PATH: '請選擇允許目錄內的本機一般檔案。', IO: '找不到檔案,或目前無法讀取。', ARGS: '請提供有效的檔案路徑。', DECODE: '無法解碼這個媒體檔案。' }[code] ?? '預覽程序失敗,請重新開啟。');
18const time = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`;
19
20 let options: PluginOptions = {};
21 let automatic = true;
22 let clickable = true;
23 let hyperlinks = false;
24 let interactive = false;
25 let polling: Timer | undefined;
26 let pollBusy = false;
27 let resetting = false;
28 let epoch = 0;
29 let paneOperation = 0;
30 let sessionKey = '';
31 let pane: View | undefined;
32 let attachments = new Map<number, Attachment>();
33 let snapshot: Attachment[] = [];
34 let shownBand = '';
35 const inline = new Map<string, { data: string; view: View }>();
36 const jobs = new Set<Job>();
37 const automaticQueue: AutomaticTask[] = [];
38 const automaticRunning = new Set<AutomaticTask>();
39 // Tombstones outlive the 16 decoded-image entries; redraw never retries an eviction.
40 const attemptedReads = new Set<string>();
41
42 const redraw = ($: Api) => $.ui.invalidate('ui.render');
43 const argv = ($: Api, args: string[]) => ['node', `${$.plugin.root}/scripts/media.mjs`, ...args];
44 function stopJob(job: Job) {
45 job.stopped = true;
46 job.stopping ??= job.stream.return({ code: null, signal: 'SIGTERM' }).then(() => undefined).catch(() => undefined);
47 return job.stopping;
48 }
49 async function stop(target?: View) {
50 for (let i = automaticQueue.length - 1; i >= 0; i--) {
51 if (automaticQueue[i]?.target === target) automaticQueue.splice(i, 1);
52 }
53 if (target) target.automaticPending = false;
54 if (!target?.job) return;
55 const job = target.job;
56 // Retain the job until return finishes so later operations share its cleanup.
57 await stopJob(job);
58 if (target.job === job) target.job = undefined;
59 jobs.delete(job);
60 }
61 async function reset() {
62 resetting = true;
63 epoch++;
64 paneOperation++;
65 polling?.cancel(); polling = undefined;
66 pane?.player?.close(); pane = undefined;
67 attachments.clear(); snapshot = []; sessionKey = ''; shownBand = ''; inline.clear();
68 automaticQueue.length = 0; attemptedReads.clear();
69 const closing = [...jobs];
70 await Promise.all(closing.map(stopJob));
71 jobs.clear();
72 resetting = false;
73 }
74 function enqueueAutomatic($: Api, target: View, args: string[], current: () => boolean, input?: string) {
75 if (target.automaticPending || target.job || !current()) return;
76 target.automaticPending = true;
77 automaticQueue.push({ target, args, current, ...(input === undefined ? {} : { input }) });
78 pumpAutomatic($);
79 }
80 function pumpAutomatic($: Api) {
81 if (!automatic || resetting) return;
82 while (automaticRunning.size < 2 && automaticQueue.length) {
83 const task = automaticQueue.shift()!;
84 if (!task.current()) { task.target.automaticPending = false; continue; }
85 automaticRunning.add(task);
86 void consume($, task.args, task.target, task.current, task.input).catch(() => {
87 if (task.current()) {
88 task.target.error = `無法預覽:${message('DECODE')}`;
89 task.target.loading = false; redraw($);
90 }
91 }).finally(() => {
92 automaticRunning.delete(task); task.target.automaticPending = false;
93 pumpAutomatic($);
94 });
95 }
96 }
97 async function consume($: Api, args: string[], target: View, current: () => boolean, input?: string, playerGeneration?: number) {
98 const job: Job = { stream: $.process.spawn({ argv: args, ...(input === undefined ? {} : { input }) }), stopped: false };
99 job.stream.result.catch(() => undefined);
100 target.job = job; jobs.add(job);
101 const parser = new NdjsonParser();
102 let ended = false;
103 let capped = false;
104 let headerSeen = false;
105 try {
106 let step = await job.stream.next();
107 media: while (!step.done) {
108 if (job.stopped || !current()) return 'CANCELLED';
109 if (step.value.stream === 'stdout') {
110 for (const record of parser.push(step.value.text)) {
111 if (job.stopped || !current()) return 'CANCELLED';
112 if (ended) throw new Error('PROTOCOL');
113 if (record.type === 'error') throw new Error(record.code);
114 if (record.type === 'header') {
115 if (headerSeen || (target.header && record.kind !== target.header.kind)) throw new Error('PROTOCOL');
116 headerSeen = true; target.header = record;
117 } else if (record.type === 'end') {
118 if (!headerSeen) throw new Error('PROTOCOL');
119 ended = true;
120 } else {
121 if (!headerSeen) throw new Error('PROTOCOL');
122 if (record.type === 'markdown') {
123 if (target.header?.kind !== 'markdown' || record.page !== target.pages.length + 1) throw new Error('PROTOCOL');
124 target.pages.push(record); target.loading = false; redraw($);
125 } else {
126 if (target.header?.kind === 'markdown') throw new Error('PROTOCOL');
127 if (target.player && playerGeneration !== undefined && target.player.generation === playerGeneration && record.time >= target.player.duration) {
128 target.player.acceptFrame(playerGeneration, { ...record, time: target.player.duration });
129 if (record.time === target.player.duration) target.frame = record;
130 target.player.end(playerGeneration); target.loading = false;
131 capped = true; redraw($);
132 // Exit this consumer, then return the host stream in finally; never await stop() on our own job.
133 break media;
134 }
135 if (target.player && (playerGeneration === undefined || !target.player.acceptFrame(playerGeneration, record))) continue;
136 target.frame = record; target.loading = false;
137 const second = target.player ? Math.floor(target.player.position) : undefined;
138 const clockChanged = second !== undefined && second !== target.displayedSecond;
139 target.displayedSecond = second;
140 if (target.mount) {
141 const result = await $.ui.blit({ requestId: target.mount.requestId, key: 'media', source: { png: record.png }, columns: target.mount.columns, rows: target.mount.rows });
142 if (result.deny || clockChanged) redraw($);
143 } else redraw($);
144 }
145 }
146 }
147 }
148 step = await job.stream.next();
149 }
150 if (capped) return;
151 if (job.stopped || !current()) return 'CANCELLED';
152 if (!step.done) throw new Error('PROTOCOL');
153 parser.finish();
154 if (step.value.code !== 0 || step.value.signal || !ended) throw new Error('DECODE');
155 target.loading = false;
156 if (target.player && playerGeneration !== undefined) target.player.end(playerGeneration);
157 redraw($);
158 } catch (error) {
159 if (!job.stopped && current()) {
160 target.error = `無法預覽:${message(error instanceof Error ? error.message : 'DECODE')}`;
161 target.frame = undefined; target.pages = []; target.loading = false;
162 if (target.player?.status === 'playing') target.player.pause();
163 redraw($);
164 }
165 return error instanceof Error ? error.message : 'DECODE';
166 } finally {
167 // Leaving the host stream is what kills the helper and its decoder.
168 await stopJob(job);
169 if (target.job === job) target.job = undefined;
170 jobs.delete(job);
171 }
172 }
173 async function openPane($: Api, target: View) {
174 const operation = ++paneOperation;
175 if (pane !== target) {
176 const previous = pane;
177 previous?.player?.close(); await stop(previous);
178 if (operation !== paneOperation || resetting) return false;
179 pane = target;
180 }
181 if (operation !== paneOperation || resetting) return false;
182 await $.ui.open({ id: 'preview', title: target.header?.name ?? '預覽', focus: true, closeOnEscape: true, rows: 24 });
183 if (operation !== paneOperation || pane !== target) return false;
184 redraw($);
185 return true;
186 }
187 async function closePane($: Api, closeSurface = true) {
188 const operation = ++paneOperation;
189 const previous = pane; pane = undefined;
190 previous?.player?.close(); await stop(previous);
191 if (operation !== paneOperation) return;
192 redraw($);
193 if (closeSurface && !pane) await $.ui.close({ id: 'preview' });
194 }
195 async function openInline($: Api, data: string) {
196 const target = view();
197 if (!await openPane($, target)) return;
198 await consume($, argv($, ['image', '--stdin-base64']), target, () => pane === target, data);
199 }
200 async function play($: Api, target: View, seek?: number) {
201 if (pane !== target || !target.player || !target.source || resetting) return;
202 // New intent invalidates old frames and pending play handlers before any await.
203 const own = ++target.generation;
204 const player = target.player;
205 const generation = seek === undefined ? player.play() : player.seek(seek);
206 target.error = ''; target.loading = !target.frame; redraw($);
207 await stop(target);
208 if (pane !== target || resetting || target.generation !== own || target.player !== player || player.generation !== generation || player.status !== 'playing') return;
209 void consume($, argv($, ['video', ...target.source, '--start', String(player.position)]), target,
210 () => pane === target && target.generation === own && target.player?.generation === generation, undefined, generation);
211 }
212 const extraRoots = () => Array.isArray(options.roots) ? options.roots.filter((value): value is string => typeof value === 'string' && value.startsWith('/')).slice(0, 32) : [];
213 async function inspect($: Api, path: string, target: View, current: () => boolean) {
214 const root = await $.session.root();
215 const extra = extraRoots();
216 let reason = 'ROOT';
217 for (const allowed of [root, ...extra]) {
218 const source = ['--root', allowed, '--path', path];
219 if (!current()) throw new Error('CANCELLED');
220 target.header = undefined; target.error = '';
221 const failure = await consume($, argv($, ['inspect', ...source]), target, current);
222 if (!current()) throw new Error('CANCELLED');
223 if (!failure && target.header) return { header: target.header as HeaderRecord, source };
224 reason = failure ?? 'DECODE';
225 if (!['ROOT', 'IO'].includes(reason)) break;
226 }
227 throw new Error(reason);
228 }
229 // `/preview <path>` and a pressed reply path share one opening: same cancel and decoder semantics.
230 async function previewPath($: Api, path: string) {
231 const target = view();
232 if (!await openPane($, target)) return '預覽已取消。';
233 const own = ++target.generation;
234 try {
235 const result = await inspect($, path, target, () => pane === target && target.generation === own);
236 if (pane !== target || target.generation !== own) return '預覽已取消。';
237 target.header = result.header; target.source = result.source;
238 target.loading = true;
239 if (result.header.kind === 'video') {
240 target.player = new Player(Math.min(600, result.header.duration ?? 0));
241 await play($, target);
242 } else await consume($, argv($, [result.header.kind, ...result.source]), target, () => pane === target && target.generation === own);
243 redraw($);
244 } catch (error) {
245 if (pane === target) { target.error = `無法預覽:${message(error instanceof Error ? error.message : 'DECODE')}`; target.loading = false; redraw($); }
246 }
247 return target.error || `已開啟預覽:${target.header?.name ?? '檔案'}。`;
248 }
249 async function poll($: Api) {
250 if (!interactive || !automatic || pollBusy || resetting) return;
251 pollBusy = true;
252 const own = epoch;
253 try {
254 const [draft, root, id] = await Promise.all([$.prompt.read(), $.session.root(), $.session.id()]);
255 if (own !== epoch || !automatic || !interactive) return;
256 const key = `${id}\n${root}`;
257 if (key !== sessionKey) {
258 for (const item of attachments.values()) void stop(item.view);
259 attachments.clear(); snapshot = []; sessionKey = key;
260 }
261 const ids = [...new Set([...draft.text.matchAll(/\[Image #([1-9][0-9]{0,8})\]/g)].map(match => Number(match[1])))].slice(0, 200);
262 for (const [number, item] of attachments) {
263 if (!ids.includes(number)) { attachments.delete(number); void stop(item.view); }
264 }
265 for (const number of ids) {
266 if (attachments.has(number)) continue;
267 const item: Attachment = { id: number, key, root, sessionId: id, view: view() };
268 attachments.set(number, item);
269 }
270 // Decode only visible thumbnails. Other attachments load on an explicit press.
271 for (const number of ids.slice(0, 2)) {
272 const item = attachments.get(number)!;
273 if (item.view.frame || item.view.job || item.view.automaticPending || item.view.error) continue;
274 enqueueAutomatic($, item.view, argv($, ['pasted', '--session-id', id, '--cwd', root, '--image-id', String(number)]),
275 () => own === epoch && automatic && sessionKey === key && attachments.get(number) === item);
276 }
277 if (ids.length) snapshot = ids.map(number => attachments.get(number)!);
278 // Redraw only when the pasted-image band would change: a redraw re-runs every reply row too.
279 const band = [...attachments.values()].map(item => `${item.id}${item.view.frame ? 'f' : ''}${item.view.job ? 'j' : ''}${item.view.error ? 'e' : ''}`).join(' ');
280 if (band !== shownBand) { shownBand = band; redraw($); }
281 } catch { /* No clipboard/history fallback; retry the current snapshot next tick. */ }
282 finally { pollBusy = false; }
283 }
284 // Each name is a literal so `claude plugin validate` lists what is read.
285 async function terminalEnv($: Api) {
286 const [FORCE_HYPERLINK, CI, WT_SESSION, TERM_PROGRAM, TERM_PROGRAM_VERSION, VTE_VERSION, TERM, TERMINAL_EMULATOR, TMUX, LC_TERMINAL] = await Promise.all([
287 $.env.get('FORCE_HYPERLINK'), $.env.get('CI'), $.env.get('WT_SESSION'), $.env.get('TERM_PROGRAM'), $.env.get('TERM_PROGRAM_VERSION'),
288 $.env.get('VTE_VERSION'), $.env.get('TERM'), $.env.get('TERMINAL_EMULATOR'), $.env.get('TMUX'), $.env.get('LC_TERMINAL'),
289 ]);
290 return { FORCE_HYPERLINK, CI, WT_SESSION, TERM_PROGRAM, TERM_PROGRAM_VERSION, VTE_VERSION, TERM, TERMINAL_EMULATOR, TMUX, LC_TERMINAL };
291 }
292 function ensurePolling($: Api) {
293 if (interactive && automatic && !polling && !resetting) {
294 polling = $.clock.every(500, () => { void poll($); });
295 }
296 }
297 function image($: Api, e: RenderInput<'Pane' | 'AbovePrompt' | 'ToolResult', 'terminal'>, target: View, maxColumns: number, maxRows: number, key = 'media') {
298 if (!target.frame) return undefined;
299 // Terminal cells are about twice as tall as wide; derive fit from decoded pixels.
300 let columns = Math.max(1, Math.min(255, maxColumns));
301 let rows = Math.max(1, Math.round(columns * target.frame.height / target.frame.width / 2));
302 if (rows > maxRows) { rows = Math.max(1, Math.min(255, maxRows)); columns = Math.max(1, Math.min(columns, Math.round(rows * target.frame.width / target.frame.height * 2))); }
303 if (key === 'media') target.mount = { requestId: e.requestId, columns, rows };
304 return $.ui.resolve(e).Image({ key, source: { png: target.frame.png }, columns, rows, alt: target.header?.name ?? '圖片預覽' });
305 }
306export const register: Register = (on, configuration) => {
307 options = configuration;
308 automatic = options.autoPreview !== false;
309 clickable = options.clickablePaths !== false;
310 on('session.start', async ($, e, next) => {
311 await reset();
312 interactive = e.isInteractive && e.surface === 'terminal';
313 // Where Claude Code draws a link as `text (url)` no press reaches it, so replies keep core's drawing there.
314 hyperlinks = interactive && hyperlinkTerminal(await terminalEnv($));
315 if (hyperlinks) redraw($);
316 await $.command.register({ name: 'preview', description: '預覽 Markdown、圖片、影片與貼圖', argumentHint: '<路徑>|pasted|close|on|off', immediate: true });
317 ensurePolling($);
318 return next(e);
319 });
320 on('session.end', async ($, e, next) => {
321 interactive = interactive && ['clear', 'resume'].includes(e.reason);
322 await reset(); redraw($);
323 return next(e);
324 });
325 on('command.run', { command: 'preview' }, async ($, e) => {
326 ensurePolling($);
327 const text = e.args.trim();
328 if (!text) return { text: '用法:/preview <路徑>、pasted、close、on、off。影片無聲音;按 Esc 關閉預覽。' };
329 if (text === 'close') { await closePane($); return { text: '已關閉預覽。' }; }
330 if (text === 'on' || text === 'off') {
331 automatic = text === 'on';
332 if (!automatic) {
333 polling?.cancel(); polling = undefined;
334 for (const item of attachments.values()) void stop(item.view);
335 for (const item of inline.values()) void stop(item.view);
336 attachments.clear(); inline.clear(); snapshot = []; epoch++;
337 automaticQueue.length = 0; attemptedReads.clear();
338 } else ensurePolling($);
339 redraw($);
340 return { text: automatic ? '已開啟自動圖片預覽。' : '已關閉自動圖片預覽;仍可使用 /preview <路徑>。' };
341 }
342 if (text === 'pasted') {
343 const sessionEpoch = epoch;
344 const [root, id] = await Promise.all([$.session.root(), $.session.id()]);
345 if (sessionEpoch !== epoch) return { text: '預覽已取消。' };
346 const items = snapshot.filter(item => item.key === `${id}\n${root}` && item.view.frame);
347 if (!items.length) return { text: '目前沒有可預覽的工作階段貼圖。請貼上圖片並等待半秒。SSH 需先將 client 圖片傳到遠端輸入框。' };
348 if (!await openPane($, { ...items[0]!.view, job: undefined, mount: undefined })) return { text: '預覽已取消。' };
349 return { text: '已開啟貼圖預覽;原輸入內容不會改寫。' };
350 }
351 const path = ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) ? text.slice(1, -1) : text;
352 return { text: await previewPath($, path) };
353 });
354 on('ui.close', { id: 'preview' }, async ($, e, next) => { await closePane($, false); return next(e); });
355 on('ui.render', { component: 'Pane', requestId: 'preview' }, async ($, e, next) => {
356 if (e.surface !== 'terminal') return next(e);
357 const { Box, Text, Markdown, Button } = $.ui.resolve(e);
358 const target = pane;
359 if (!target) return Text({ children: ['目前沒有預覽。'] });
360 const children: RenderElement[] = [];
361 if (target.error) children.push(Text({ color: 'red', children: [target.error] }));
362 else if (target.loading && !target.frame && !target.pages.length) children.push(Text({ children: ['正在準備預覽…'] }));
363 const picture = image($, e, target, e.props.bodyColumns, Math.max(1, (e.viewport?.rows ?? 30) - 8));
364 if (picture) children.push(picture);
365 if (target.pages.length) {
366 const page = target.pages[target.page]!;
367 children.push(Markdown({ text: page.text }), Text({ children: [`第 ${target.page + 1} / ${target.pages.length} 頁`] }), Box({ flexDirection: 'row', children: [
368 Button({ key: 'page-previous', label: '上一頁', hotkey: 'h', onPress: () => { target.page = Math.max(0, target.page - 1); redraw($); } }),
369 Button({ key: 'page-next', label: '下一頁', hotkey: 'l', onPress: () => { target.page = Math.min(target.pages.length - 1, target.page + 1); redraw($); } }),
370 ] }));
371 }
372 if (target.player) {
373 const player = target.player;
374 children.push(Text({ children: [`${({ playing: '播放中', paused: '暫停', ended: '播放結束', closed: '已關閉' })[player.status]} ${time(player.position)} / ${time(player.duration)} 無聲音${(target.header?.duration ?? 0) > 600 ? '(本次僅預覽前 10 分鐘)' : ''}`] }), Box({ flexDirection: 'row', children: [
375 player.status === 'playing' ? Button({ key: 'pause', label: '暫停', hotkey: 'p', onPress: async () => { if (pane !== target || player.status === 'closed') return; ++target.generation; player.pause(); await stop(target); redraw($); } }) : Button({ key: 'play', label: '播放', hotkey: 'p', onPress: async () => play($, target) }),
376 Button({ key: 'seek-backward', label: '-5 秒', hotkey: 'h', onPress: async () => play($, target, player.position - 5) }),
377 Button({ key: 'seek-forward', label: '+5 秒', hotkey: 'l', onPress: async () => play($, target, player.position + 5) }),
378 Button({ key: 'replay', label: '重播', hotkey: 'r', onPress: async () => play($, target, 0) }),
379 ] }));
380 }
381 children.push(Button({ key: 'close', label: '關閉(Esc)', hotkey: 'x', role: 'dismiss', onPress: async () => closePane($) }));
382 return Box({ flexDirection: 'column', children });
383 });
384 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
385 ensurePolling($);
386 if (e.surface !== 'terminal' || !automatic || e.props.hasSurvey || !attachments.size) return next(e);
387 const { Box, Text, Button } = $.ui.resolve(e);
388 const items = [...attachments.values()];
389 const pictures = items.slice(0, 2).map(item => image($, e, item.view, Math.max(1, Math.floor(e.props.bodyColumns / 2) - 1), Math.max(1, Math.min(8, e.props.maxRows - 3)), `pasted-image:${item.id}`)).filter((item): item is RenderElement => !!item);
390 return Box({ flexDirection: 'column', children: [Text({ children: [`送出前貼圖預覽(${items.length} 張)`] }), Box({ flexDirection: 'row', children: pictures }), Box({ flexDirection: 'row', children: items.map(item => Button({ key: `pasted:${item.id}`, label: `圖片 ${item.id}${item.view.error ? ':無法預覽' : item.view.job ? ':準備中' : ''}`, onPress: async () => {
391 const sessionEpoch = epoch;
392 const [root, id] = await Promise.all([$.session.root(), $.session.id()]);
393 if (sessionEpoch !== epoch || `${id}\n${root}` !== item.key) return;
394 const target = { ...item.view, job: undefined, mount: undefined, error: '' };
395 if (!await openPane($, target)) return;
396 if (!target.frame) await consume($, argv($, ['pasted', '--session-id', id, '--cwd', root, '--image-id', String(item.id)]), target, () => pane === target);
397 } })) })] });
398 });
399 on('ui.render', { component: 'ToolResult', props: { tool: 'Read' } }, async ($, e, next) => {
400 const renderingEpoch = epoch;
401 const base = await next(e);
402 if (renderingEpoch !== epoch || resetting || !interactive || e.surface !== 'terminal' || !automatic || e.props.isErrored) return base;
403 if (e.props.onScreen === null) {
404 const hidden = inline.get(e.requestId);
405 if (hidden) { void stop(hidden.view); inline.delete(e.requestId); }
406 return base;
407 }
408 const output = e.props.output as { type?: unknown; file?: { base64?: unknown; type?: unknown } } | null;
409 const data = output?.file?.base64;
410 if (output?.type !== 'image' || typeof data !== 'string' || !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(String(output.file?.type)) || data.length > 44_739_244) return base;
411 let cached = inline.get(e.requestId);
412 if ((!cached || cached.data !== data) && !attemptedReads.has(e.requestId) && attemptedReads.size < 128) {
413 if (cached) void stop(cached.view);
414 cached = { data, view: view() }; inline.set(e.requestId, cached);
415 while (inline.size > 16) { const first = inline.keys().next().value!; void stop(inline.get(first)?.view); inline.delete(first); }
416 attemptedReads.add(e.requestId);
417 const own = cached; const generation = epoch;
418 enqueueAutomatic($, own.view, argv($, ['image', '--stdin-base64']), () => automatic && generation === epoch && inline.get(e.requestId) === own, data);
419 }
420 const { Box, Text, Button } = $.ui.resolve(e);
421 if (!cached || cached.data !== data) return Box({ flexDirection: 'column', children: [base, Button({ key: 'read-preview', label: '顯示圖片', onPress: async () => openInline($, data) })] });
422 const picture = image($, e, cached.view, Math.min(80, e.viewport?.columns ?? 80), 16);
423 return Box({ flexDirection: 'column', children: [base, ...(picture ? [picture] : [Text({ dimColor: true, children: [cached.view.error || '正在準備圖片預覽…'] })])] });
424 });
425 // A reply naming media paths is redrawn with those paths as links this Mod answers on a plain
426 // click; the stored message and the model's input stay as they were, and nothing is read until then.
427 // Only the fullscreen layout of a terminal with clickable links reports a press; elsewhere, and for
428 // a reply naming no media file, core's drawing is returned before any engine call.
429 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
430 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || !interactive || !clickable || !hyperlinks || resetting || !mentionsMedia(e.props.text)) return next(e);
431 const renderingEpoch = epoch;
432 const root = await $.session.root();
433 const reply = renderingEpoch === epoch ? linkifyMediaPaths(e.props.text, root, extraRoots()) : undefined;
434 if (!reply) return next(e);
435 const { Box, Text, Markdown } = $.ui.resolve(e);
436 // Core's reply row: a margin, the bullet gutter on a reply's first block, then the prose.
437 return Box({ flexDirection: 'row', alignItems: 'flex-start', width: '100%', marginTop: 1, children: [
438 ...(e.props.isFirstOfReply ? [Box({ minWidth: 2, children: [Text({ children: ['⏺'] })] })] : []),
439 Box({ flexDirection: 'column', children: [Markdown({ key: 'reply-paths', text: reply.text, pressableLinks: [...reply.links.keys()], onLinkPress: link => {
440 // A hook beneath may rewrite the href: only a path this drawing linked opens.
441 const path = pathFromHref(link.href);
442 if (path && [...reply.links.values()].includes(path) && interactive && !resetting) void previewPath($, path);
443 } })] }),
444 ] });
445 });
446};
447hooks/protocol.ts 66 lines1export type MediaKind = 'markdown' | 'image' | 'video';
2export interface HeaderRecord {
3 type: 'header'; kind: MediaKind; name: string; media: string;
4 width?: number; height?: number; duration?: number;
5}
6export interface FrameRecord { type: 'frame'; png: string; time: number; width: number; height: number }
7export interface MarkdownRecord { type: 'markdown'; text: string; page: number; totalPages: number }
8export interface ErrorRecord { type: 'error'; code: string; message: string }
9export type MediaRecord = HeaderRecord | FrameRecord | MarkdownRecord | ErrorRecord | { type: 'end' };
10export const MAX_RECORD_CHARS = 3 * 1024 * 1024;
11const controls = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
12const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0;
13const dimension = (value: unknown, max: number): boolean => finite(value) && Number.isInteger(value) && value > 0 && value <= max;
14const text = (value: unknown, max: number): boolean => typeof value === 'string' && value.length <= max && !controls.test(value);
15
16export function parseRecord(line: string): MediaRecord {
17 const record: unknown = JSON.parse(line);
18 if (!record || typeof record !== 'object') throw new Error('Invalid media record');
19 const r = record as Record<string, unknown>;
20 let valid = false;
21 switch (r.type) {
22 case 'header':
23 valid = typeof r.kind === 'string' && ['markdown', 'image', 'video'].includes(r.kind) && text(r.name, 240) && text(r.media, 100)
24 && (r.width === undefined || dimension(r.width, 100_000)) && (r.height === undefined || dimension(r.height, 100_000))
25 && (r.duration === undefined || finite(r.duration));
26 break;
27 case 'frame':
28 valid = typeof r.png === 'string' && r.png.startsWith('iVBORw0KGgo') && r.png.length <= 2_796_204
29 && r.png.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(r.png)
30 && r.png.length / 4 * 3 - (r.png.endsWith('==') ? 2 : r.png.endsWith('=') ? 1 : 0) <= 2_097_152
31 && finite(r.time) && dimension(r.width, 640) && dimension(r.height, 360);
32 break;
33 case 'markdown':
34 valid = text(r.text, 9000) && dimension(r.totalPages, 2_097_152) && dimension(r.page, Number(r.totalPages));
35 break;
36 case 'error': valid = text(r.code, 80) && text(r.message, 500); break;
37 case 'end': valid = true; break;
38 }
39 if (!valid) throw new Error('Invalid media record');
40 return record as MediaRecord;
41}
42
43/** UTF-8 spawn chunks contain base64, never binary image bytes. */
44export class NdjsonParser {
45 private pending = '';
46 constructor(private readonly limit = MAX_RECORD_CHARS) {
47 if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_RECORD_CHARS) throw new Error('Invalid record limit');
48 }
49 push(chunk: string): MediaRecord[] {
50 const records: MediaRecord[] = [];
51 let offset = 0;
52 while (offset < chunk.length) {
53 const newline = chunk.indexOf('\n', offset);
54 const end = newline < 0 ? chunk.length : newline;
55 if (this.pending.length + end - offset > this.limit) { this.pending = ''; throw new Error('Media record exceeds limit'); }
56 this.pending += chunk.slice(offset, end);
57 if (newline < 0) break;
58 const line = this.pending; this.pending = '';
59 if (line) records.push(parseRecord(line));
60 offset = newline + 1;
61 }
62 return records;
63 }
64 finish(): void { if (this.pending.length) { this.pending = ''; throw new Error('Truncated media record'); } }
65}
66hooks/player.ts 40 lines1import type { FrameRecord } from './protocol.js';
2export type PlayerStatus = 'paused' | 'playing' | 'ended' | 'closed';
3
4/** Owns playback state only. The caller aborts its decoder whenever generation changes. */
5export class Player {
6 status: PlayerStatus = 'paused';
7 position = 0;
8 generation = 0;
9 constructor(readonly duration: number) {
10 if (!Number.isFinite(duration) || duration <= 0) throw new Error('Invalid video duration');
11 }
12 play(): number {
13 this.assertOpen();
14 if (this.status === 'ended' || this.position >= this.duration) this.position = 0;
15 this.status = 'playing';
16 return ++this.generation;
17 }
18 pause(): void { this.assertOpen(); this.status = 'paused'; ++this.generation; }
19 seek(position: number): number {
20 this.assertOpen();
21 if (!Number.isFinite(position)) throw new Error('Invalid seek position');
22 this.position = Math.max(0, Math.min(this.duration, position));
23 this.status = this.position >= this.duration ? 'ended' : 'playing';
24 return ++this.generation;
25 }
26 acceptFrame(generation: number, frame: FrameRecord): boolean {
27 if (this.status !== 'playing' || generation !== this.generation || !Number.isFinite(frame.time)
28 || frame.time < this.position || frame.time > this.duration) return false;
29 this.position = frame.time;
30 return true;
31 }
32 end(generation: number): boolean {
33 if (this.status !== 'playing' || generation !== this.generation) return false;
34 this.status = 'ended'; ++this.generation;
35 return true;
36 }
37 close(): void { this.status = 'closed'; ++this.generation; }
38 private assertOpen(): void { if (this.status === 'closed') throw new Error('Player is closed'); }
39}
40hooks/links.ts 192 lines1// Media paths in a reply become file: links the Mod answers on a plain click;
2// every other character of the reply is drawn as written. Nothing here reads a
3// file: media.mjs still decides root containment, type and size on the click.
4
5const EXTENSIONS = 'png|jpe?g|webp|gif|mp4|mov|webm|mkv';
6const MEDIA = new RegExp(`\\.(?:${EXTENSIONS})$`, 'i');
7const MENTION = new RegExp(`\\.(?:${EXTENSIONS})(?!\\w)`, 'i');
8const CONTROL = /[\u0000-\u001f\u007f]/;
9const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
10const EMAIL = /^[\w.+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
11// The targets a plugin-drawn Markdown keeps clickable; a reply with any other keeps core's drawing.
12const KEPT_SCHEME = /^(?:https?|file):/i;
13// Core's rule: a relative target's first segment may hold only these characters.
14const KEPT_RELATIVE = /^[A-Za-z0-9._~-]*(?:[/?#]|$)/;
15// A fence may sit under a list item, open on its marker line or sit in a quote; over-detection only leaves text unlinked.
16const CONTAINER = '(?:[ \\t>]|[-*+][ \\t]|\\d{1,9}[.)][ \\t])*';
17const FENCE = new RegExp(`^${CONTAINER}(\`{3,}|~{3,})`);
18const FENCE_CLOSE = new RegExp(`^${CONTAINER}[\`~]+\\s*$`);
19// A definition is its label, its destination and at most a title; anything else on the line is prose.
20const REFERENCE = /^( {0,3}\[[^\]\n]+\]:[ \t]*)<?([^\s<>]+)>?((?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?[ \t]*)$/;
21// A definition may also follow a heading, a thematic break or a setext underline.
22const BLOCK_END = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|([-*_])(?:[ \t]*\1){2,}[ \t]*$|=+[ \t]*$|-+[ \t]*$)/;
23// A definition inside a quote or list item is left as written rather than followed through its container.
24const CONTAINED_DEFINITION = new RegExp(`^[ \\t]*(?:>|[-*+][ \\t]|\\d{1,9}[.)][ \\t])${CONTAINER}\\[[^\\]\\n]+\\]:`);
25// Markdown link with an optional title, code span, <autolink or HTML>, URL, then one maximal run
26// of path characters. A label or <…> stops at its own opener, a code span starts only at the
27// head of a backtick run and a scheme is at most 32 characters, so no position is rescanned.
28// Bare paths are ASCII so prose glued to them (存到content/a.png了) stays outside the link.
29const TOKEN = new RegExp([
30 '(?<bang>!?)\\[(?<label>[^\\[\\]\\n]*)\\]\\([ \\t]*(?<target>[^()\\s]+)(?<title>[ \\t]+(?:"[^"\\n]*"|\'[^\'\\n]*\'|\\([^()\\n]*\\)))?[ \\t]*\\)',
31 '(?<!`)(?=(?<ticks>`+))\\k<ticks>(?<code>[^`\\n]+?)\\k<ticks>',
32 '<(?<angle>[^<>\\n]*)>',
33 '(?:[A-Za-z][A-Za-z0-9+.-]{0,31}:\\/\\/|www\\.)[^\\s<>()]*',
34 '(?<bare>[\\w.~/@-]+)',
35].join('|'), 'g');
36
37export const MAX_TEXT = 10_000;
38export const MAX_LINKS = 256;
39const MAX_HREF = 2048;
40
41export type LinkedReply = { text: string; links: Map<string, string> };
42
43export function fileHref(path: string) {
44 return `file://${encodeURI(path).replace(/[?#()]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`;
45}
46
47export function pathFromHref(href: string) {
48 if (!href.startsWith('file:///')) return undefined;
49 let path: string;
50 try { path = decodeURIComponent(href.slice('file://'.length)); } catch { return undefined; }
51 return CONTROL.test(path) ? undefined : path;
52}
53
54function normalize(path: string) {
55 const parts: string[] = [];
56 for (const part of path.split('/')) {
57 if (part === '' || part === '.') continue;
58 if (part !== '..') parts.push(part);
59 else if (parts.pop() === undefined) return undefined;
60 }
61 return `/${parts.join('/')}`;
62}
63
64export function linkifyMediaPaths(markdown: string, root: string, extraRoots: readonly string[]): LinkedReply | undefined {
65 if (markdown.length > MAX_TEXT) return undefined;
66 const roots = [root, ...extraRoots].map(normalize).filter((value): value is string => !!value);
67 if (!roots.length) return undefined;
68 const links = new Map<string, string>();
69 const mediaPath = (raw: string) => {
70 if (!MEDIA.test(raw) || CONTROL.test(raw) || raw.startsWith('~') || SCHEME.test(raw)) return undefined;
71 const path = normalize(raw.startsWith('/') ? raw : `${roots[0]}/${raw}`);
72 return path && roots.some(base => path.startsWith(base === '/' ? '/' : `${base}/`)) ? path : undefined;
73 };
74 // One href per path; past MAX_LINKS or the href ceiling the path stays plain text.
75 const hrefOf = (path: string | undefined) => {
76 if (!path) return undefined;
77 const href = fileHref(path);
78 if (href.length > MAX_HREF || (!links.has(href) && links.size >= MAX_LINKS)) return undefined;
79 links.set(href, path);
80 return href;
81 };
82 // A link the plugin Markdown would draw as `text (url)` makes the whole reply keep core's drawing.
83 let keepsCore = false;
84 const keep = (target: string) => { if (!(SCHEME.test(target) ? KEPT_SCHEME : KEPT_RELATIVE).test(target)) keepsCore = true; };
85 const escaped = (line: string, offset: number) => /\\*$/.exec(line.slice(0, offset))![0].length % 2 === 1;
86 const linkLine = (line: string) => line.replace(TOKEN, (...args) => {
87 const whole = args[0] as string;
88 const offset = args.at(-3) as number;
89 const { bang, label, target, title = '', ticks, code, angle, bare } = args.at(-1) as Record<string, string | undefined>;
90 if (target !== undefined) {
91 if (bang || escaped(line, offset)) return whole;
92 const href = hrefOf(target.startsWith('file:') ? mediaPath(pathFromHref(target) ?? '') : mediaPath(target));
93 if (href) return `[${label}](${href}${title})`;
94 keep(target);
95 return whole;
96 }
97 if (ticks !== undefined) {
98 const raw = code!.trim();
99 // A spaced span is a path only when it starts like one (`/Users/me/Screen Shot.png`), not a command.
100 if (escaped(line, offset) || (/\s/.test(raw) && !/^\.{0,2}\//.test(raw))) return whole;
101 const href = hrefOf(mediaPath(raw));
102 return href ? `[${whole}](${href})` : whole;
103 }
104 if (angle !== undefined) {
105 if (SCHEME.test(angle) || EMAIL.test(angle)) keep(EMAIL.test(angle) ? `mailto:${angle}` : angle);
106 return whole;
107 }
108 if (bare === undefined) return whole;
109 if (EMAIL.test(bare)) { keepsCore = true; return whole; }
110 // The target of a link the link pattern could not take (nested brackets, an escape) stays as written.
111 if (/\]\([ \t]*$/.test(line.slice(Math.max(0, offset - 64), offset))) return whole;
112 // A sentence's closing dots trail the path; a bare token must hold a directory.
113 const path = bare.replace(/\.+$/, '');
114 const href = path.includes('/') ? hrefOf(mediaPath(path)) : undefined;
115 return href ? `[${path}](${href})${bare.slice(path.length)}` : whole;
116 });
117 let fence: { char: string; size: number } | undefined;
118 // A definition starts the text or follows a blank line, a fence, a heading, a rule or another definition; it cannot interrupt a paragraph.
119 let definitionMayStart = true;
120 const lines = markdown.split('\n').map(line => {
121 const marker = FENCE.exec(line)?.[1];
122 if (fence) {
123 if (marker && marker[0] === fence.char && marker.length >= fence.size && FENCE_CLOSE.test(line)) { fence = undefined; definitionMayStart = true; }
124 return line;
125 }
126 if (marker) { fence = { char: marker[0]!, size: marker.length }; return line; }
127 const definition = definitionMayStart ? REFERENCE.exec(line) : null;
128 if (definition) {
129 // Only the destination may become the file href; label and title stay as written.
130 const target = definition[2]!;
131 const href = hrefOf(target.startsWith('file:') ? mediaPath(pathFromHref(target) ?? '') : mediaPath(target));
132 if (!href) keep(target);
133 return href ? `${definition[1]}${href}${definition[3]}` : line;
134 }
135 if (CONTAINED_DEFINITION.test(line)) { definitionMayStart = false; return line; }
136 definitionMayStart = !line.trim() || BLOCK_END.test(line);
137 return linkLine(line);
138 });
139 const text = lines.join('\n');
140 return links.size && !keepsCore && text.length <= MAX_TEXT ? { text, links } : undefined;
141}
142
143// Whether a reply could name a media file at all: the cheap test before any linkify work.
144export function mentionsMedia(text: string) {
145 return MENTION.test(text);
146}
147
148export type TerminalEnv = Readonly<Record<string, string | undefined>>;
149
150// Claude Code 2.1.289 draws a link as a clickable cell only where its hyperlink check holds
151// (G_ over supports-hyperlinks); elsewhere a link draws as `text (url)` and no press arrives.
152const LINK_TERMINALS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal'];
153function version(value = '') {
154 if (/^\d{3,4}$/.test(value)) return { major: 0, minor: parseInt(/(\d{1,2})(\d{2})/.exec(value)?.[1] ?? '', 10) };
155 const [major = NaN, minor = NaN] = value.split('.').map(part => parseInt(part, 10));
156 return { major, minor };
157}
158// supports-hyperlinks for a colour TTY; NETLIFY, TEAMCITY_VERSION and CLI flags are left out, so it only errs towards false.
159function streamLinks(env: TerminalEnv) {
160 if (!env.TERM || env.TERM === 'dumb') return false;
161 if (env.WT_SESSION !== undefined) return true;
162 if (env.CI) return false;
163 const program = version(env.TERM_PROGRAM_VERSION);
164 switch (env.TERM_PROGRAM) {
165 case 'iTerm.app': return program.major === 3 ? program.minor >= 1 : program.major > 3;
166 case 'WezTerm': return program.major >= 20200620;
167 case 'vscode': return program.major > 1 || (program.major === 1 && program.minor >= 72);
168 case 'ghostty': return true;
169 }
170 if (env.VTE_VERSION) {
171 if (env.VTE_VERSION === '0.50.0') return false;
172 const vte = version(env.VTE_VERSION);
173 return vte.major > 0 || vte.minor >= 50;
174 }
175 return env.TERM === 'alacritty';
176}
177export function hyperlinkTerminal(env: TerminalEnv) {
178 const force = env.FORCE_HYPERLINK;
179 if (force !== undefined) return force ? parseInt(force, 10) !== 0 : streamLinks(env);
180 if (streamLinks(env)) return true;
181 const program = env.TERM_PROGRAM;
182 if (program && LINK_TERMINALS.includes(program)) return true;
183 if (env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') return true;
184 if (env.WT_SESSION && program !== 'tmux' && !env.TMUX) return true;
185 if (program === 'tmux') {
186 const tmux = version(env.TERM_PROGRAM_VERSION);
187 if (tmux.major > 3 || (tmux.major === 3 && tmux.minor >= 4)) return true;
188 }
189 if (env.LC_TERMINAL && LINK_TERMINALS.includes(env.LC_TERMINAL)) return true;
190 return !!env.TERM?.includes('kitty');
191}
192