Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

<!-- SECTION:DESCRIPTION:BEGIN -->
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/<id>/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.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [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)
<!-- AC:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
76 changes: 59 additions & 17 deletions src/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,14 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ 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];
Expand All @@ -780,8 +788,11 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ 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++;
Expand Down Expand Up @@ -819,9 +830,14 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ 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++;
Expand Down Expand Up @@ -862,6 +878,21 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ 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 },
Expand All @@ -872,23 +903,34 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ 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 },
});
Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/task-107-md-path-wrap-and-spaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ async function installFileReadSpy(window: Page): Promise<void> {
});
}

// 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<void> {
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<string[]> {
return window.evaluate(() => ((window as any).__fileReadCalls || []).slice());
}
Expand Down Expand Up @@ -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();
}
});
});