Skip to content

Commit 9ed5509

Browse files
committed
Arm wrapped URLs whole and cover agent plain rows
1 parent e2342c6 commit 9ed5509

6 files changed

Lines changed: 552 additions & 45 deletions

File tree

‎docs/TUI.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,8 @@ url.dll,FileProtocolHandler` — argv spawns, never through a shell).
842842
prefix-match. Hover highlighting needs pointer-motion reports, so the
843843
main shell enables them (`enableMouseMovement`, DEC ?1003) alongside
844844
the existing capture; the pickers and setup screens stay opted out.
845+
- Wrapped URLs resolve whole: a URL broken across continuation lines
846+
highlights and opens as the one target from any of its fragments.
845847
- Markdown prose (assistant messages) is not covered: the renderer paints
846848
it through childless code renderers with no stable text-leaf API to
847849
highlight or hit-test, so those links stay terminal business until the

‎src/tui/shell/transcript.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
findLinks,
1919
paintLinkLine,
2020
splitLinkSpans,
21+
splitWrappedLinkSpans,
2122
} from "../url-links.js";
2223
import {
2324
splitAtSettledHeading,
@@ -33,6 +34,7 @@ import {
3334
isSentenceRow,
3435
MAIN_AGENT,
3536
paintStreamRow,
37+
plainRowWrapWidth,
3638
rowGroupGap,
3739
streamRowGutter,
3840
toolRowLines,
@@ -205,7 +207,12 @@ function retextStreamRowBody(
205207
return false;
206208
if (node instanceof TextRenderable) {
207209
if (isMarkdownRow(row)) return false;
208-
paintPlainRowNode(node, paintStreamRow(row, layout));
210+
paintPlainRowNode(
211+
node,
212+
row,
213+
paintStreamRow(row, layout),
214+
plainRowWrapWidth(row, layout),
215+
);
209216
return true;
210217
}
211218

@@ -354,7 +361,12 @@ export function buildRowNode(
354361
}
355362

356363
if (!isMarkdownRow(row)) {
357-
return buildPlainRowNode(ctx, paintStreamRow(row, layout));
364+
return buildPlainRowNode(
365+
ctx,
366+
row,
367+
paintStreamRow(row, layout),
368+
plainRowWrapWidth(row, layout),
369+
);
358370
}
359371

360372
const gutter = streamRowGutter(row, layout);
@@ -385,24 +397,37 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) {
385397
*/
386398
function buildPlainRowNode(
387399
ctx: CliRenderer,
400+
row: StreamRow,
388401
painted: PaintedStreamLine,
402+
wrapWidth: number,
389403
): TextRenderable {
390404
const node = new TextRenderable(ctx, {
391405
content: painted.content,
392406
fg: painted.fg,
393407
});
394-
paintPlainRowNode(node, painted);
408+
paintPlainRowNode(node, row, painted, wrapWidth);
395409
return node;
396410
}
397411

412+
/**
413+
* Links a plain row's pre-wrap text holds: wrapped fragments reassemble to
414+
* one of these, which is what tells a real wrap across a short fragment line
415+
* apart from a natural line break after the fact.
416+
*/
417+
function plainRowSourceUrls(row: StreamRow): string[] {
418+
return findLinks(`${row.text}\n${row.summary ?? ""}`).map((hit) => hit.url);
419+
}
420+
398421
/**
399422
* Rewrite a plain row's text on its existing node. The node never changes
400423
* shape, so a URL appearing or disappearing repaints in place instead of
401424
* forcing a rebuild.
402425
*/
403426
function paintPlainRowNode(
404427
node: TextRenderable,
428+
row: StreamRow,
405429
painted: PaintedStreamLine,
430+
wrapWidth: number,
406431
): void {
407432
const lines = painted.content.split("\n");
408433
if (!lines.some((line) => findLinks(line).length > 0)) {
@@ -415,7 +440,11 @@ function paintPlainRowNode(
415440
}
416441
paintLinkLine(
417442
node,
418-
lines.map((line) => splitLinkSpans([{ text: line, fg: painted.fg }])),
443+
splitWrappedLinkSpans(
444+
lines.map((text) => ({ text: text.trimEnd(), fg: painted.fg })),
445+
wrapWidth,
446+
plainRowSourceUrls(row),
447+
),
419448
);
420449
}
421450

‎src/tui/stream.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,25 @@ function userBubbleLines(text: string, width: number): string[] {
387387
];
388388
}
389389

390+
/**
391+
* The painted width a plain row was wrapped at, for reassembling URLs split
392+
* across continuation lines. Mirrors the three plain-row layouts: the bubble
393+
* wraps its body inside the bar, while thinking and gutter-indented rows fill
394+
* the full width. Keep in sync with userBubbleLines/thinkingLines/indentBody.
395+
*/
396+
export function plainRowWrapWidth(row: StreamRow, layout: RowLayout): number {
397+
if (row.role !== "user") return layout.width;
398+
const barWidth = stringWidth(`${BUBBLE_BAR} `);
399+
const body = Math.max(
400+
1,
401+
Math.min(
402+
layout.width - barWidth,
403+
Math.ceil(layout.width * BUBBLE_MAX_SHARE),
404+
),
405+
);
406+
return body + barWidth;
407+
}
408+
390409
/**
391410
* Columns a reasoning block is inset by. The inset plus the faintest text in
392411
* the palette is the whole of reasoning's chrome — it carries no marker of its

‎src/tui/url-click.test.ts‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,140 @@ describe("Ctrl+clicking a transcript URL", () => {
253253
{ width: 80, height: 24 },
254254
);
255255
});
256+
257+
test("a wrapped URL in a thinking row opens the full target", async () => {
258+
await withTestRenderer(
259+
async (h) => {
260+
const shell = createAppShell(h.renderer, {
261+
terminal: { columns: 40, rows: 24 },
262+
wireKeys: false,
263+
run: "idle",
264+
});
265+
const opened: string[] = [];
266+
setUrlOpener((url) => {
267+
opened.push(url);
268+
});
269+
try {
270+
// Agent thinking paints through the same plain-row path as user
271+
// rows; the long URL wraps mid-run at this width. Before the fix
272+
// the row armed the first fragment as its own truncated target.
273+
const full =
274+
"https://example.com/abcdefghijklmnopqrstuvwxyz0123456789";
275+
appendStreamRow(shell, {
276+
role: "system",
277+
meta: "thinking",
278+
text: `checking ${full} today`,
279+
});
280+
await h.renderOnce();
281+
282+
const link = findCell(h.captureCharFrame(), "example.com");
283+
expect(link).not.toBeNull();
284+
const at = defined(link);
285+
286+
await h.mockMouse.click(at.x, at.y, 0, {
287+
modifiers: { ctrl: true },
288+
});
289+
await h.renderOnce();
290+
expect(opened).toEqual([full]);
291+
} finally {
292+
resetUrlOpener();
293+
shell.dispose();
294+
}
295+
},
296+
{ width: 40, height: 24 },
297+
);
298+
});
299+
300+
test("a URL wrapped across bubble lines opens the full target", async () => {
301+
await withTestRenderer(
302+
async (h) => {
303+
const shell = createAppShell(h.renderer, {
304+
terminal: { columns: 40, rows: 24 },
305+
wireKeys: false,
306+
run: "idle",
307+
});
308+
const opened: string[] = [];
309+
setUrlOpener((url) => {
310+
opened.push(url);
311+
});
312+
try {
313+
// The bubble body is narrower than the terminal, so the long URL
314+
// wraps across continuation rows. Every fragment must resolve to
315+
// the one target, not to its own truncated text.
316+
const full =
317+
"https://example.com/abcdefghijklmnopqrstuvwxyz0123456789";
318+
appendStreamRow(shell, {
319+
role: "user",
320+
text: `see ${full} ok`,
321+
});
322+
await h.renderOnce();
323+
324+
const link = findCell(h.captureCharFrame(), "example.com");
325+
expect(link).not.toBeNull();
326+
const at = defined(link);
327+
328+
await h.mockMouse.click(at.x, at.y, 0, {
329+
modifiers: { ctrl: true },
330+
});
331+
await h.renderOnce();
332+
expect(opened).toEqual([full]);
333+
} finally {
334+
resetUrlOpener();
335+
shell.dispose();
336+
}
337+
},
338+
{ width: 40, height: 24 },
339+
);
340+
});
341+
342+
test("assistant markdown links stay terminal business (no opener call)", async () => {
343+
await withTestRenderer(
344+
async (h) => {
345+
const shell = createAppShell(h.renderer, {
346+
terminal: { columns: 80, rows: 24 },
347+
wireKeys: false,
348+
run: "idle",
349+
});
350+
const opened: string[] = [];
351+
setUrlOpener((url) => {
352+
opened.push(url);
353+
});
354+
try {
355+
// Markdown prose paints through childless library renderers with
356+
// no text-leaf API to arm or hit-test (docs/TUI.md), so neither
357+
// the bare URL nor the explicit link label opens through us.
358+
// The real terminal owns those cells; this pins that remainder.
359+
appendStreamRow(shell, {
360+
role: "assistant",
361+
text: "see https://example.com/docs and [guide](https://example.com/guide) ok",
362+
});
363+
// Assistant rows are markdown; their blocks highlight
364+
// asynchronously (see shell.test.ts), so the frame only carries
365+
// the prose after a settle.
366+
await new Promise((resolve) => setTimeout(resolve, 250));
367+
await h.renderOnce();
368+
369+
const bare = findCell(h.captureCharFrame(), "example.com/docs");
370+
expect(bare).not.toBeNull();
371+
await h.mockMouse.click(defined(bare).x, defined(bare).y, 0, {
372+
modifiers: { ctrl: true },
373+
});
374+
await h.renderOnce();
375+
expect(opened).toEqual([]);
376+
377+
const label = findCell(h.captureCharFrame(), "guide");
378+
expect(label).not.toBeNull();
379+
await h.mockMouse.click(defined(label).x, defined(label).y, 0, {
380+
modifiers: { ctrl: true },
381+
});
382+
await h.renderOnce();
383+
expect(opened).toEqual([]);
384+
} finally {
385+
resetUrlOpener();
386+
shell.dispose();
387+
}
388+
},
389+
{ width: 80, height: 24 },
390+
);
391+
});
256392
});

0 commit comments

Comments
 (0)