From 00d682883542e1cb73933b303cc8db2aa076ed6a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:21:34 -0800 Subject: [PATCH] Open long /tmp paths that Claude Code hard-wrapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+click did nothing useful on Claude Code's scratchpad paths (/tmp/claude-/-workspace//scratchpad/x.md). Claude Code wraps its own output: a word longer than the line is broken mid-word and the rest drawn on the next row with cursor moves, so xterm never marks that row isWrapped. The link provider saw two fragments: the first has no extension (no link), the second is a relative `…/scratchpad/x.md` probed under /workspace and not found. - xtermLineJoin bridges such breaks when given cols: the row runs to the edge, the next row continues with path characters after its indent, and the two runs could not have fit on one line (keeps prose apart). - Backend: `~/p` resolves to /home/claude/p; a relative path the project roots do not have is probed again under /home/claude and /tmp, in a second exec so a project file never becomes a chooser. - CLAUDE_INSTRUCTIONS asks Claude to print full absolute paths for files outside the project (changes the fingerprint: one recreation per project). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- .../src/commands/file_viewer_commands.rs | 15 ++- app/src-tauri/src/docker/container.rs | 28 ++++++ app/src-tauri/src/file_viewer/resolve.rs | 83 ++++++++++++++++- .../terminal/filePathLinkProvider.test.ts | 26 +++++- .../terminal/filePathLinkProvider.ts | 6 +- app/src/lib/xtermLineJoin.test.ts | 63 ++++++++++++- app/src/lib/xtermLineJoin.ts | 91 +++++++++++++++++-- 8 files changed, 300 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6040fd9..71977d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input` in `file_viewer/mod.rs` is the only thing pinning this. + **Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word + longer than the line (a `/tmp/claude-/…` scratchpad path) is broken mid-word and the rest + drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols` + (row runs to the edge, next row continues with path characters, and the two runs could not + have fit on one line — the last test is what keeps prose from being glued together). - **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar - **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`, and the editors reused by Project Home @@ -172,7 +177,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`, `auth_token_commands.rs`. - **`file_viewer/`** — one window per click (`file-viewer-`), a managed `ViewerRegistry`, - resolution by probing `/workspace/

` then `/workspace//

` in one exec as `claude`, + resolution by probing `/workspace/

` then `/workspace//

` in one exec as `claude` + (`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/

`, `/tmp/

`), polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container user (spec §5 says why the archive API never writes to the target directory). Commands take `window: tauri::Window`, gate on the label and act on the caller's own registry entry — no diff --git a/app/src-tauri/src/commands/file_viewer_commands.rs b/app/src-tauri/src/commands/file_viewer_commands.rs index 2af9605..dc9f65f 100644 --- a/app/src-tauri/src/commands/file_viewer_commands.rs +++ b/app/src-tauri/src/commands/file_viewer_commands.rs @@ -15,7 +15,7 @@ use crate::file_viewer::poll::{poll_file, ViewerPoll}; use crate::file_viewer::registry::{ Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState, }; -use crate::file_viewer::resolve::{candidate_paths, probe_candidates}; +use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates}; use crate::file_viewer::window::open_viewer_window; use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES}; use crate::models::Project; @@ -159,8 +159,17 @@ pub async fn open_file_viewer( let container_id = running_container_of(&project, "opening files").await?; let mounts: Vec = project.paths.iter().map(|p| p.mount_name.clone()).collect(); - let candidates = candidate_paths(&path, &mounts)?; - let matches = probe_candidates(&container_id, &candidates).await?; + let mut candidates = candidate_paths(&path, &mounts)?; + let mut matches = probe_candidates(&container_id, &candidates).await?; + // Not in the project: try home and /tmp, as a second exec so a project file + // never becomes a chooser because a namesake sits in /tmp. + if matches.is_empty() { + let fallbacks = fallback_candidate_paths(&path); + if !fallbacks.is_empty() { + matches = probe_candidates(&container_id, &fallbacks).await?; + candidates.extend(fallbacks); + } + } let initial = Location { line, col, end_line }; let target_state = match matches.len() { diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 6bda607..81642a9 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -124,6 +124,15 @@ Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it on every container start. To turn sandbox off, stop the container in Triple-C, flip the "Sandbox mode" switch off, then start the container."#; +/// The Triple-C terminal opens a printed path on Shift+click by looking for it +/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere +/// (Claude Code's scratchpad under `/tmp/claude-/...` above all) cannot +/// be found, so ask for the whole path. Part of the instructions fingerprint, +/// so adding or changing it recreates each container once on its next start. +const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files + +The user can Shift+click a file path in this terminal to open it in a viewer. Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened."#; + /// Build the full CLAUDE_INSTRUCTIONS value by merging global + project /// instructions, appending port mapping docs, and appending scheduler docs. /// Used by both create_container() and container_needs_recreation() to ensure @@ -163,6 +172,11 @@ fn build_claude_instructions( None => SCHEDULER_INSTRUCTIONS.to_string(), }); + combined = Some(match combined { + Some(existing) => format!("{}\n\n{}", existing, FILE_LINK_INSTRUCTIONS), + None => FILE_LINK_INSTRUCTIONS.to_string(), + }); + if sandbox_enabled { combined = Some(match combined { Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS), @@ -4137,6 +4151,20 @@ mod tests { const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL; const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL; + /// The terminal can only open a file it can find: a bare name is looked + /// for in the project, home and /tmp, but a long scratchpad path printed + /// as `scratchpad/notes.md` is nowhere. Every container gets the ask to + /// print the full path, whatever else is configured. + #[test] + fn every_container_is_asked_to_print_full_paths_for_files_outside_the_project() { + for (global, project, sandbox) in [(None, None, false), (Some("g"), Some("p"), true)] { + let s = build_claude_instructions(global, project, &[], false, sandbox).unwrap(); + assert!(s.contains(FILE_LINK_INSTRUCTIONS), "{:?}", (global, project, sandbox)); + } + assert!(FILE_LINK_INSTRUCTIONS.contains("full absolute path")); + assert!(FILE_LINK_INSTRUCTIONS.contains("/tmp")); + } + fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> { compute_model_aliases(model, haiku) } diff --git a/app/src-tauri/src/file_viewer/resolve.rs b/app/src-tauri/src/file_viewer/resolve.rs index b026444..0a51004 100644 --- a/app/src-tauri/src/file_viewer/resolve.rs +++ b/app/src-tauri/src/file_viewer/resolve.rs @@ -2,7 +2,9 @@ //! //! Relative paths are the common case (Claude prints project-relative paths). The //! terminal exec's cwd is `/workspace`, and each project path is mounted at -//! `/workspace/`, so those are the roots probed, in that order. The probe +//! `/workspace/`, so those are the roots probed, in that order. +//! A relative path none of them has is then tried under `/home/claude` and `/tmp` +//! ([`fallback_candidate_paths`]); `~/p` is `/home/claude/p`. The probe //! is one exec as the container user and prints `realpath -e` of every candidate that //! is a regular file: `fetch_container_file` refuses a symlink, so the registry must //! hold the resolved path, not the one that was clicked. @@ -36,12 +38,20 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result, return Ok(vec![normalised]); } - let rel = collapse(raw.strip_prefix("./").unwrap_or(raw)); - let rel = rel.trim_start_matches("./"); - if rel.is_empty() { - return Err("The path is empty.".into()); + // Claude Code prints paths under $HOME as `~/...`. Only the bare `~/` form: + // `~user/` names some other account's home and stays an ordinary name. + if let Some(rest) = raw.strip_prefix("~/") { + let rest = collapse(rest); + if rest.is_empty() { + return Err("The path is empty.".into()); + } + let p = format!("{}/{}", CONTAINER_HOME, rest); + validate_container_path("File", &p)?; + return Ok(vec![p]); } + let rel = relative_part(raw).ok_or_else(|| "The path is empty.".to_string())?; + let mut out: Vec = Vec::new(); let mut push = |candidate: String| { if out.len() < MAX_CANDIDATES && !out.contains(&candidate) { @@ -61,6 +71,37 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result, Ok(out) } +/// Where a relative path is looked for when no project root has it, in order. +/// The container user's home, then `/tmp` (drops, pastes and scratch files). +/// Probed only after [`candidate_paths`] found nothing, so a project file never +/// turns into a "which one?" chooser because a same-named file sits in `/tmp`. +const FALLBACK_ROOTS: &[&str] = &[CONTAINER_HOME, "/tmp"]; +const CONTAINER_HOME: &str = "/home/claude"; + +/// The second tier for a relative path: [`FALLBACK_ROOTS`]`/

`. Empty for an +/// absolute or `~/` path (those name one place already) and for anything +/// [`candidate_paths`] refuses — it is always called after that succeeded, but +/// must not be a way around its checks. +pub fn fallback_candidate_paths(raw: &str) -> Vec { + if raw.starts_with('/') || raw.starts_with("~/") || candidate_paths(raw, &[]).is_err() { + return Vec::new(); + } + let Some(rel) = relative_part(raw) else { return Vec::new() }; + FALLBACK_ROOTS + .iter() + .map(|root| format!("{}/{}", root, rel)) + .filter(|c| validate_container_path("File", c).is_ok()) + .take(MAX_CANDIDATES) + .collect() +} + +/// `./a//b` → `a/b`; `None` when nothing is left. +fn relative_part(raw: &str) -> Option { + let rel = collapse(raw.strip_prefix("./").unwrap_or(raw)); + let rel = rel.trim_start_matches("./"); + (!rel.is_empty()).then(|| rel.to_string()) +} + /// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs). fn collapse(path: &str) -> String { let absolute = path.starts_with('/'); @@ -129,6 +170,38 @@ mod tests { assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]); } + #[test] + fn an_absolute_tmp_path_is_accepted_as_is() { + // Claude Code's scratchpad: long, and with a segment that starts with `-`. + let p = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md"; + assert_eq!(candidate_paths(p, &mounts(&["api"])).unwrap(), vec![p]); + } + + #[test] + fn a_home_relative_path_is_the_container_users_home() { + // Claude Code prints paths under $HOME as `~/...`. + let c = candidate_paths("~/.claude/plans/plan.md", &mounts(&["api"])).unwrap(); + assert_eq!(c, vec!["/home/claude/.claude/plans/plan.md"]); + assert!(candidate_paths("~/", &[]).is_err()); + assert!(candidate_paths("~/../etc/passwd", &[]).is_err()); + // `~user/` is not expanded; it stays an ordinary relative name. + assert_eq!(candidate_paths("~bob/x.md", &[]).unwrap(), vec!["/workspace/~bob/x.md"]); + } + + #[test] + fn a_relative_path_falls_back_to_home_and_tmp() { + assert_eq!( + fallback_candidate_paths("scratch/notes.md"), + vec!["/home/claude/scratch/notes.md", "/tmp/scratch/notes.md"] + ); + assert_eq!(fallback_candidate_paths("./notes.md"), vec!["/home/claude/notes.md", "/tmp/notes.md"]); + // Absolute, home-relative and refused paths have no fallbacks. + assert!(fallback_candidate_paths("/tmp/notes.md").is_empty()); + assert!(fallback_candidate_paths("~/notes.md").is_empty()); + assert!(fallback_candidate_paths("../notes.md").is_empty()); + assert!(fallback_candidate_paths("").is_empty()); + } + #[test] fn traversal_nul_and_oversize_are_refused() { assert!(candidate_paths("../etc/passwd", &[]).is_err()); diff --git a/app/src/components/terminal/filePathLinkProvider.test.ts b/app/src/components/terminal/filePathLinkProvider.test.ts index 43119c3..b3d5945 100644 --- a/app/src/components/terminal/filePathLinkProvider.test.ts +++ b/app/src/components/terminal/filePathLinkProvider.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { createFilePathLinkProvider } from "./filePathLinkProvider"; -const fakeTerm = (rows: Array<[string, boolean]>) => ({ +const fakeTerm = (rows: Array<[string, boolean]>, cols?: number) => ({ + cols, buffer: { active: { getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] }, @@ -40,6 +41,29 @@ describe("createFilePathLinkProvider", () => { expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } }); }); + it("links a long /tmp path that Claude Code hard-wrapped, from either row", () => { + // Captured from Claude Code at 70 columns: the path is broken mid-word and + // the rest drawn two cells in on the next row, with no `isWrapped`. + const rows: Array<[string, boolean]> = [ + ["\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e", false], + [" 039e2a07562/scratchpad/notes.md ok", false], + ]; + const onOpen = vi.fn(); + const provider = createFilePathLinkProvider(fakeTerm(rows, 70), onOpen, () => true); + for (const row of [1, 2]) { + const links = vi.fn(); + provider.provideLinks(row, links); + const [list] = links.mock.calls[0]; + expect(list).toHaveLength(1); + expect(list[0].range).toEqual({ start: { x: 18, y: 1 }, end: { x: 33, y: 2 } }); + list[0].activate(new MouseEvent("click"), list[0].text); + } + const scratch = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md"; + expect(onOpen).toHaveBeenCalledTimes(2); + expect(onOpen).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: scratch })); + expect(onOpen).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: scratch })); + }); + it("answers undefined for a row with nothing", () => { const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true); const links = vi.fn(); diff --git a/app/src/components/terminal/filePathLinkProvider.ts b/app/src/components/terminal/filePathLinkProvider.ts index e6fbda5..565635f 100644 --- a/app/src/components/terminal/filePathLinkProvider.ts +++ b/app/src/components/terminal/filePathLinkProvider.ts @@ -17,7 +17,7 @@ export interface FilePathHover { } export function createFilePathLinkProvider( - term: Pick, + term: Pick, onOpen: (match: FilePathMatch) => void, gate: (event: MouseEvent) => boolean, hover?: FilePathHover, @@ -27,7 +27,9 @@ export function createFilePathLinkProvider( const row = bufferLineNumber - 1; const line = term.buffer.active.getLine(row); if (!line) return callback(undefined); - const joined = joinWrappedRows(term.buffer.active, row); + // `cols` lets the join bridge Claude Code's own mid-word wraps, which is + // how a long `/tmp/claude-/...` path reaches the screen. + const joined = joinWrappedRows(term.buffer.active, row, { cols: term.cols }); // `offsetToCell` has no row to map onto when nothing was joined. if (joined.rowStarts.length === 0) return callback(undefined); const matches = findFilePathLinks(joined.text); diff --git a/app/src/lib/xtermLineJoin.test.ts b/app/src/lib/xtermLineJoin.test.ts index b50d08b..b034d41 100644 --- a/app/src/lib/xtermLineJoin.test.ts +++ b/app/src/lib/xtermLineJoin.test.ts @@ -10,7 +10,7 @@ const buffer = (rows: Array<[string, boolean]>): RowSource => ({ describe("joinWrappedRows", () => { it("returns a single unwrapped row as-is", () => { const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0); - expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] }); + expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0], rowIndents: [0] }); }); it("walks up to the row that started the wrap and down through continuations", () => { @@ -38,6 +38,67 @@ describe("joinWrappedRows", () => { }); }); +/** + * Claude Code wraps its own output: a word longer than the line is broken + * mid-word and the rest is drawn on the next row with cursor moves, so xterm + * never marks that row `isWrapped`. Captured from Claude Code at 70 columns + * (bytes: `...9329-e\r\x1b[2C\x1b[1B039e2a...`). + */ +const CLAUDE_ROW_1 = "\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e"; +const CLAUDE_ROW_2 = " 039e2a07562/scratchpad/notes.md ok"; +const SCRATCH = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md"; + +describe("joinWrappedRows across Claude Code's hard wraps", () => { + it("the captured row really does end at the right edge", () => { + expect(CLAUDE_ROW_1.length).toBe(70); + }); + + it("joins a path broken mid-word onto the next row, dropping the indent", () => { + const b = buffer([["before", false], [CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false], ["after", false]]); + const j = joinWrappedRows(b, 1, { cols: 70 }); + expect(j.text).toBe(`\u25cf The file is at ${SCRATCH} ok`); + expect(j.firstRow).toBe(1); + expect(j.rowStarts).toEqual([0, 70]); + expect(j.rowIndents).toEqual([0, 2]); + // Asked from the continuation row, the same line comes back. + expect(joinWrappedRows(b, 2, { cols: 70 })).toEqual(j); + // The first character of the continuation is drawn at column 3. + expect(offsetToCell(j, 70)).toEqual({ x: 3, y: 3 }); + }); + + it("joins a path broken over three rows", () => { + const cols = 30; + const path = "/tmp/" + "a".repeat(60) + "/notes.md"; // 74 chars + const rows: Array<[string, boolean]> = [ + [path.slice(0, 30), false], + [" " + path.slice(30, 58), false], + [" " + path.slice(58), false], + ]; + const j = joinWrappedRows(buffer(rows), 2, { cols }); + expect(j.text).toBe(path); + expect(j.rowIndents).toEqual([0, 2, 2]); + }); + + it("does not glue an ordinary word at the edge to a short word below it", () => { + const cols = 40; + const row1 = "Run the tests and then go and look at it"; // ends at the edge + expect(row1.length).toBe(cols); + const b = buffer([[row1, false], [" src/main.rs for details", false]]); + expect(joinWrappedRows(b, 0, { cols }).text).toBe(row1); + expect(joinWrappedRows(b, 1, { cols }).text).toBe(" src/main.rs for details"); + }); + + it("does not bridge a row that stops short of the edge", () => { + const b = buffer([["/tmp/claude-1000/-workspace/abc", false], ["def/notes.md", false]]); + expect(joinWrappedRows(b, 1, { cols: 70 }).text).toBe("def/notes.md"); + }); + + it("does not bridge without knowing the width", () => { + const b = buffer([[CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false]]); + expect(joinWrappedRows(b, 0).text).toBe(CLAUDE_ROW_1); + }); +}); + describe("offsetToCell", () => { it("maps offsets to 1-based cells on the right row", () => { const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] }; diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts index 1be6501..28fc63b 100644 --- a/app/src/lib/xtermLineJoin.ts +++ b/app/src/lib/xtermLineJoin.ts @@ -1,6 +1,10 @@ /** * Joins an xterm buffer row with its wrapped continuations. * + * Two kinds: xterm's own soft wraps (`isWrapped`), and — when the caller + * passes the terminal width — the mid-word breaks Claude Code makes itself, + * which xterm cannot see (see `hardWrapIndent`). + * * `WebLinksAddon` does the same in its private `LinkComputer`, which the * built package does not export — so the walk is repeated here, with the same * 2048-character budget. Rows are read with `translateToString(true)`, which @@ -20,28 +24,97 @@ export interface RowSource { getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined; } +export interface JoinOptions { + /** + * The terminal's width. Given, rows that Claude Code broke mid-word are + * joined too (see `hardWrapIndent`); omitted, only xterm's soft wraps are. + */ + cols?: number; +} + export interface JoinedLine { text: string; /** 0-based index of the first buffer row that contributed. */ firstRow: number; /** For each contributed row (in order), the string offset at which it starts. */ rowStarts: number[]; + /** + * For each contributed row, how many leading cells were left out of `text`: + * the indent of a hard-wrapped continuation, 0 for every other row. + */ + rowIndents: number[]; } -export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine { +/** The characters `findFilePathLinks`' token is made of. */ +const PATH_CHAR = /[A-Za-z0-9_.\-~+@/]/; +const TRAILING_PATH_RUN = /[A-Za-z0-9_.\-~+@/]+$/; +const LEADING_PATH_RUN = /^[A-Za-z0-9_.\-~+@/]+/; +/** Cells a full row may stop short of the right edge (Ink boxes pad by one or two). */ +const EDGE_SLACK = 2; + +/** + * Is `next` the rest of a word that Claude Code broke at the end of `prev`? + * Returns the continuation's indent (cells to skip), or `null`. + * + * Claude Code wraps its own output (Ink / wrap-ansi) and draws continuation + * rows with cursor moves, so xterm never marks them `isWrapped` — captured at + * 70 columns: `…/5e4ab145-915e-4902-9329-e\r\x1b[2C\x1b[1B039e2a…/notes.md`. + * It breaks *inside* a word only when the word is longer than the line, which + * is exactly what a long `/tmp/claude-/…` path is. So bridge only when + * `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a + * path character, `next` starts (after its indent) with one, and the two runs + * together would not have fit on one line — i.e. word wrap could not have + * moved the word down whole. That last test is what keeps ordinary prose (a + * word that happens to end at the edge above `src/main.rs`) from being glued. + */ +function hardWrapIndent(prev: string, next: string, cols: number): number | null { + if (prev.length < cols - EDGE_SLACK || prev.length > cols) return null; + if (!PATH_CHAR.test(prev[prev.length - 1])) return null; + const indent = next.search(/\S/); + if (indent < 0 || !PATH_CHAR.test(next[indent])) return null; + const tail = TRAILING_PATH_RUN.exec(prev)?.[0].length ?? 0; + const head = LEADING_PATH_RUN.exec(next.slice(indent))?.[0].length ?? 0; + if (tail + head <= cols - indent - EDGE_SLACK) return null; + return indent; +} + +/** + * Cells to skip if row `y` continues row `y - 1`: 0 for an xterm soft wrap, + * the indent for a Claude Code hard wrap, `null` if `y` starts a new line. + */ +function continuationIndent(buffer: RowSource, y: number, cols: number | undefined): number | null { + if (y <= 0) return null; + const line = buffer.getLine(y); + if (!line) return null; + if (line.isWrapped) return 0; + if (!cols) return null; + const prev = buffer.getLine(y - 1); + if (!prev) return null; + return hardWrapIndent(prev.translateToString(true), line.translateToString(true), cols); +} + +export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOptions = {}): JoinedLine { + const { cols } = options; let top = row; - while (top > 0 && buffer.getLine(top)?.isWrapped) top--; + while (top > 0 && continuationIndent(buffer, top, cols) !== null) top--; const parts: string[] = []; + const rowIndents: number[] = []; let length = 0; let y = top; for (;;) { const line = buffer.getLine(y); if (!line) break; - if (y !== top && !line.isWrapped) break; - const text = line.translateToString(true); + let indent = 0; + if (y !== top) { + const c = continuationIndent(buffer, y, cols); + if (c === null) break; + indent = c; + } + const text = line.translateToString(true).slice(indent); if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break; parts.push(text); + rowIndents.push(indent); length += text.length; y++; } @@ -52,14 +125,18 @@ export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine { rowStarts.push(offset); offset += p.length; } - return { text: parts.join(""), firstRow: top, rowStarts }; + return { text: parts.join(""), firstRow: top, rowStarts, rowIndents }; } /** String offset → 1-based {x, y} cell (y is the buffer row + 1). */ -export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } { +export function offsetToCell( + joined: Pick & Partial>, + offset: number, +): { x: number; y: number } { let rowIdx = 0; for (let i = 0; i < joined.rowStarts.length; i++) { if (joined.rowStarts[i] <= offset) rowIdx = i; } - return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 }; + const indent = joined.rowIndents?.[rowIdx] ?? 0; + return { x: offset - joined.rowStarts[rowIdx] + indent + 1, y: joined.firstRow + rowIdx + 1 }; }