Open long /tmp paths that Claude Code hard-wrapped
Shift+click did nothing useful on Claude Code's scratchpad paths (/tmp/claude-<uid>/-workspace/<session>/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 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc531be499
commit
00d6828835
8 files changed
+300
-20
No files matched your search
@@ -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-<uid>/…` 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-<n>`), a managed `ViewerRegistry`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`
|
||||
(`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/<p>`, `/tmp/<p>`),
|
||||
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
|
||||
|
||||
@@ -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<String> = 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() {
|
||||
|
||||
@@ -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-<uid>/...` 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)
|
||||
}
|
||||
|
||||
@@ -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/<mount_name>`, so those are the roots probed, in that order. The probe
|
||||
//! `/workspace/<mount_name>`, 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<Vec<String>,
|
||||
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<String> = 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<Vec<String>,
|
||||
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`]`/<p>`. 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<String> {
|
||||
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<String> {
|
||||
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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface FilePathHover {
|
||||
}
|
||||
|
||||
export function createFilePathLinkProvider(
|
||||
term: Pick<Terminal, "buffer">,
|
||||
term: Pick<Terminal, "buffer" | "cols">,
|
||||
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-<uid>/...` 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);
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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-<uid>/…` 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<JoinedLine, "firstRow" | "rowStarts"> & Partial<Pick<JoinedLine, "rowIndents">>,
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user