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:
shadowdaoandClaude Opus 5.5 committed 2026-11-02 10:01:37 -08:00
1 parent fc531be499
commit 00d6828835
8 files changed
+300 -20

No files matched your search

+7 -1
View File
@@ -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() {
+28
View File
@@ -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)
}
+78 -5
View File
@@ -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);
+62 -1
View File
@@ -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] };
+84 -7
View File
@@ -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 };
}