From db5f5a9e2471b1174be8f8e4c22da5d9af044ba8 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 21 May 2026 17:26:35 +0300 Subject: [PATCH] TASK-166: fix .md preview when Ink hard-wraps a no-space path Ink-based TUIs (Copilot CLI, Claude Code) hard-wrap long paths and emit the continuation row with layout indent. TASK-132/137's seam-space heuristic could not distinguish that indent from a wrap-eaten literal space, so it always inserted a phantom space at the join (.../files/... became .../fi les/...) and fileRead 404'd silently. Fix: track each inserted seam-space offset during forward and backward hard-newline stitching. In activate(), compute a seam-stripped variant of the matched path and retry fileRead with it if the primary read returns null. Primary is tried first so OneDrive - Microsoft\... paths still resolve normally. Added an e2e regression test plus an installSelectiveFileReadSpy helper that returns content only for the real path, asserting both the primary attempt and the stripped-path fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-path-hard-wraps-without-literal-spaces.md | 41 +++++++++ src/renderer/components/TerminalPanel.tsx | 76 +++++++++++++---- .../task-107-md-path-wrap-and-spaces.spec.ts | 83 +++++++++++++++++++ 3 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 backlog/tasks/task-166 - Fix-md-preview-not-opening-when-path-hard-wraps-without-literal-spaces.md diff --git a/backlog/tasks/task-166 - Fix-md-preview-not-opening-when-path-hard-wraps-without-literal-spaces.md b/backlog/tasks/task-166 - Fix-md-preview-not-opening-when-path-hard-wraps-without-literal-spaces.md new file mode 100644 index 00000000..e47e6e51 --- /dev/null +++ b/backlog/tasks/task-166 - Fix-md-preview-not-opening-when-path-hard-wraps-without-literal-spaces.md @@ -0,0 +1,41 @@ +--- +id: TASK-166 +title: Fix .md preview not opening when path hard-wraps without literal spaces (phantom seam) +status: Done +assignee: + - '@copilot' +created_date: '2026-05-21 17:10' +updated_date: '2026-05-21 17:10' +labels: [] +dependencies: [] +--- + +## Description + + +When Ink-based TUIs (Copilot CLI, Claude Code) hard-wrap a long .md path that has no embedded spaces, the continuation row starts with Ink's layout indent. The TASK-132/137 seam-space heuristic in TerminalPanel.tsx's .md link provider could not distinguish "wrap-eaten literal space" (`OneDrive - Microsoft\…`) from "pure layout indent" (`.../files/…`), so it always inserted a phantom space at the join. + +User repro: terminal pane shrunk narrow enough to wrap a path printed by the Copilot CLI session-state directory; clicking the underlined link did nothing; DevTools console showed: + +``` +[md-link] fileRead returned null { fullPath: '/Users/mimer/.copilot/session-state//fi les/reddit-scout-2026-05-21.md' } +``` + +The real on-disk path was `…/files/…` — the seam-space heuristic inserted a phantom space between `fi` and `les`. + +Fix: track each inserted seam-space offset during both forward and backward hard-newline stitching. In `activate()`, retry `fileRead` with the seam-stripped path when the primary read returns null. Primary (with seams) is tried first so legitimate paths like `OneDrive - Microsoft\file.md` still resolve normally. + + +## Acceptance Criteria + +- [x] #1 Hard-newline-wrapped no-space path opens the preview when clicked (regression test added) +- [x] #2 Existing `OneDrive - Microsoft\...`-style paths still open via the primary attempt (no fallback needed) +- [x] #3 `fileRead` is called at most twice per click (primary + optional stripped fallback) +- [x] #4 Pre-existing TASK-107 e2e test cases unchanged (no behavioral regression in single-line, soft-wrapped, bare, or two-adjacent paths) + + +## Final Summary + + +TerminalPanel.tsx's .md link provider now records the offset of every seam space inserted by the TASK-132/137 hard-newline-stitch heuristic. The `activate()` handler computes a seam-stripped variant of the matched path, tries `fileRead` on the primary (with-seam) path first, and falls back to the stripped path if the primary returns null. The first successful read drives the markdownPreview overlay. Order preserves backward compatibility: paths with genuine embedded spaces (OneDrive - Microsoft\…) still succeed on the primary attempt, while phantom-seam paths (Ink layout indent misread as wrap-eaten space) now recover on the fallback. Added a new e2e regression test (`phantom seam: hard-newline-wrapped no-space path falls back to stripped path on fileRead null`) plus a `installSelectiveFileReadSpy` helper that returns content only for the real path, asserting both that the primary attempt is tried first and that the fallback opens the overlay with the correct path. + diff --git a/src/renderer/components/TerminalPanel.tsx b/src/renderer/components/TerminalPanel.tsx index 0c3bdbf8..ed87c63c 100644 --- a/src/renderer/components/TerminalPanel.tsx +++ b/src/renderer/components/TerminalPanel.tsx @@ -759,6 +759,14 @@ const TerminalPanel: React.FC = ({ terminalId, floatTitleBar // at 4 rows so a screen full of path-shaped tokens can't glue together. const PATH_BODY = /[A-Za-z0-9._\-+~/\\]/; const MAX_HARD_NEWLINE = 4; + // TASK-166: TASK-132/137 always inserts a seam space when the + // continuation row has leading whitespace, on the assumption that the + // WS is a wrap-eaten space from a path with embedded spaces. But Ink + // also leaves leading WS as pure layout indent (no eaten space), so + // for a no-space path like `.../files/reddit-...md` we end up with a + // phantom space (`.../fi les/...`) and fileRead 404s. Track each + // inserted seam offset so activate() can retry without them. + const seamSpaceOffsets: number[] = []; let stitchedFwd = 0; while (stitchedFwd < MAX_HARD_NEWLINE) { const lastSeg = segs[segs.length - 1]; @@ -780,8 +788,11 @@ const TerminalPanel: React.FC = ({ terminalId, floatTitleBar // TASK-132: paths with literal embedded spaces (e.g. `OneDrive - // Microsoft\...`) survive the wrap iff Ink kept the space on the // post-wrap side as leading whitespace. Restore one seam space so - // the stitched path keeps the on-disk spelling. + // the stitched path keeps the on-disk spelling. We can't tell here + // whether the WS is a real eaten space or just layout indent, so + // record the seam and let activate() try both forms. const seamSpace = wsMatch[1].length > 0 ? ' ' : ''; + if (seamSpace) seamSpaceOffsets.push(logical.length); segs.push({ rowIdx: nextRow, logicalStart: logical.length + seamSpace.length, soft: false, leadingWS: wsMatch[1].length - seamSpace.length }); logical += seamSpace + trimmed; stitchedFwd++; @@ -819,9 +830,14 @@ const TerminalPanel: React.FC = ({ terminalId, floatTitleBar // Other segs shift left in `logical` by wsLen. firstSeg.leadingWS += wsLen; for (let i = 1; i < segs.length; i++) segs[i].logicalStart -= wsLen; + // Existing forward-stitch seam offsets shifted too. + for (let i = 0; i < seamSpaceOffsets.length; i++) seamSpaceOffsets[i] -= wsLen; } const shift = prevText.length + seamSpace.length; for (const s of segs) s.logicalStart += shift; + for (let i = 0; i < seamSpaceOffsets.length; i++) seamSpaceOffsets[i] += shift; + // The newly prepended seam space (if any) sits right after prevText. + if (seamSpace) seamSpaceOffsets.push(prevText.length); segs.unshift({ rowIdx: prevRow, logicalStart: 0, soft: false, leadingWS: 0 }); logical = prevText + seamSpace + logical; stitchedBack++; @@ -862,6 +878,21 @@ const TerminalPanel: React.FC = ({ terminalId, floatTitleBar const startX = lineIdx0 === a.row ? a.col + 1 : 1; const endX = lineIdx0 === b.row ? b.col + 1 : term.cols; + // TASK-166: build the seam-stripped variant for this match so + // activate() can fall back when the seam-space heuristic added a + // phantom space inside a no-space path (Ink's layout indent gets + // misread as a wrap-eaten space). Offsets are relative to the path. + const matchStart = match.index; + const matchEnd = match.index + matchedPath.length; + const seamsInPath = seamSpaceOffsets + .filter((o) => o >= matchStart && o < matchEnd) + .map((o) => o - matchStart) + .sort((x, y) => y - x); // descending so splices don't shift + let strippedPath = matchedPath; + for (const o of seamsInPath) { + strippedPath = strippedPath.slice(0, o) + strippedPath.slice(o + 1); + } + links.push({ range: { start: { x: startX, y: bufferLineNumber }, @@ -872,23 +903,34 @@ const TerminalPanel: React.FC = ({ terminalId, floatTitleBar activate() { const termInst = useTerminalStore.getState().terminals.get(terminalId); const cwd = termInst?.cwd || ''; - let fullPath = matchedPath; - if (!/^[a-zA-Z]:/.test(matchedPath) && !matchedPath.startsWith('/') && !matchedPath.startsWith('~')) { - const sep = cwd.includes('\\') ? '\\' : '/'; - fullPath = cwd + sep + matchedPath; - } - (window.terminalAPI as any).fileRead(fullPath).then((content: string | null) => { - if (content === null) { - // eslint-disable-next-line no-console - console.warn('[md-link] fileRead returned null', { fullPath }); - return; + const resolve = (p: string) => { + if (!/^[a-zA-Z]:/.test(p) && !p.startsWith('/') && !p.startsWith('~')) { + const sep = cwd.includes('\\') ? '\\' : '/'; + return cwd + sep + p; } - const fileName = fullPath.split(/[/\\]/).pop() || fullPath; - useTerminalStore.setState({ markdownPreview: { filePath: fullPath, content, fileName } }); - }).catch((err: unknown) => { - // eslint-disable-next-line no-console - console.error('[md-link] fileRead threw', { fullPath, err }); - }); + return p; + }; + const primary = resolve(matchedPath); + const fallback = strippedPath !== matchedPath ? resolve(strippedPath) : null; + const tryRead = (p: string): Promise<{ path: string; content: string } | null> => + (window.terminalAPI as any).fileRead(p).then((c: string | null) => + c === null ? null : { path: p, content: c }, + ); + tryRead(primary) + .then((res) => (res ? res : fallback ? tryRead(fallback) : null)) + .then((res) => { + if (!res) { + // eslint-disable-next-line no-console + console.warn('[md-link] fileRead returned null', { primary, fallback }); + return; + } + const fileName = res.path.split(/[/\\]/).pop() || res.path; + useTerminalStore.setState({ markdownPreview: { filePath: res.path, content: res.content, fileName } }); + }) + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error('[md-link] fileRead threw', { primary, fallback, err }); + }); }, decorations: { underline: true, pointerCursor: true }, }); diff --git a/tests/e2e/task-107-md-path-wrap-and-spaces.spec.ts b/tests/e2e/task-107-md-path-wrap-and-spaces.spec.ts index da3d1d5b..102b457b 100644 --- a/tests/e2e/task-107-md-path-wrap-and-spaces.spec.ts +++ b/tests/e2e/task-107-md-path-wrap-and-spaces.spec.ts @@ -61,6 +61,24 @@ async function installFileReadSpy(window: Page): Promise { }); } +// Spy that returns content only when the requested path matches `acceptPath`, +// null otherwise. Lets us assert that the link-provider's fallback retry +// (phantom-seam-stripped path) is what actually opens the preview. +async function installSelectiveFileReadSpy(window: Page, acceptPath: string): Promise { + await window.evaluate((accept: string) => { + (window as any).__fileReadCalls = []; + const api = (window as any).terminalAPI; + Object.defineProperty(api, 'fileRead', { + value: async (p: string) => { + (window as any).__fileReadCalls.push(p); + return p === accept ? 'mock-content' : null; + }, + configurable: true, + writable: true, + }); + }, acceptPath); +} + async function getFileReadCalls(window: Page): Promise { return window.evaluate(() => ((window as any).__fileReadCalls || []).slice()); } @@ -359,4 +377,69 @@ test.describe('TASK-107: .md path click survives spaces and soft-wrap', () => { await close(); } }); + + // TASK-166: Ink-style TUIs (Copilot CLI, Claude Code) hard-wrap a long + // no-space path and emit the continuation row with layout indent. The + // TASK-132/137 seam-space heuristic could not distinguish "wrap-eaten + // space" from "pure layout indent", so it always inserted a phantom space + // at the join (`.../files/...` -> `.../fi les/...`) and fileRead 404'd. + // Fix: track each inserted seam offset; activate() retries with the seam + // stripped if the first read returns null. + test('phantom seam: hard-newline-wrapped no-space path falls back to stripped path on fileRead null', async () => { + const { window, close } = await launchTmax(); + try { + await window.waitForSelector('.terminal-panel', { timeout: 15_000 }); + await window.waitForTimeout(800); + + // The on-disk path: no embedded spaces. Stripped path must succeed. + const realPath = '/Users/mimer/.copilot/session-state/abc/files/reddit-scout-2026-05-21.md'; + await installSelectiveFileReadSpy(window, realPath); + + // Simulate Ink output: write the head of the path, a hard newline, + // then leading whitespace (layout indent) + the tail. No isWrapped + // flag - this triggers the hard-newline stitch where the seam-space + // heuristic used to misfire. + const head = '/Users/mimer/.copilot/session-state/abc/fi'; + const tailIndent = ' '; + const tail = 'les/reddit-scout-2026-05-21.md'; + await parkCursorAt(window, 28, 1); + await writeToTerminal(window, head + '\r\n' + tailIndent + tail); + await window.waitForTimeout(400); + + // Find the tail row and click somewhere on it (e.g., inside `les/`). + const tailRows = await findRowsWithText(window, 'reddit-scout'); + expect(tailRows.length).toBe(1); + const tailY = tailRows[0].y; + + const links = await getLinksOnRow(window, tailY); + const mdLinks = links.filter(l => l.text.endsWith('reddit-scout-2026-05-21.md')); + console.log('phantom-seam link texts:', mdLinks.map(l => l.text)); + expect(mdLinks.length).toBeGreaterThanOrEqual(1); + // Stitched text still contains the phantom space (the heuristic + // can't tell at provideLinks time) - the fix lives in activate(). + expect(mdLinks[0].text).toContain('fi les/'); + + const pt = await cellPixel(window, tailY, tailIndent.length + 2); + await window.mouse.move(pt.x, pt.y); + await window.waitForTimeout(120); + await window.mouse.click(pt.x, pt.y); + await window.waitForTimeout(500); + + const calls = await getFileReadCalls(window); + console.log('phantom-seam fileRead calls:', calls); + // First attempt is the with-seam path (fails); second is the stripped + // path (succeeds). Order matters - the primary must be tried first so + // legitimate `OneDrive - Microsoft` paths still resolve normally. + expect(calls.length).toBe(2); + expect(calls[0]).toContain('fi les/'); + expect(calls[1]).toBe(realPath); + + const preview = await getMarkdownPreview(window); + expect(preview).not.toBeNull(); + expect(preview!.filePath).toBe(realPath); + expect(preview!.fileName).toBe('reddit-scout-2026-05-21.md'); + } finally { + await close(); + } + }); });