fix(tui): make alt screen not segment on - and / (#8676)

* fix(tui): make alt screen not segment on - and /

* cleanup
This commit is contained in:
Cristina Poncela Cubeiro 2026-08-26 14:33:47 +02:00 • committed by GitHub
parent b07e17faa2
commit 1ac6128e66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 7 deletions

View file

@ -66,6 +66,9 @@ const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
const DOUBLE_CLICK_INTERVAL_MS = 500;
// Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
const wordSegmenter = getWordSegmenter();
interface CachedKittyImage {
@ -832,18 +835,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
private getWordSelection(point: SelectionPoint): SelectionRange | undefined {
const line = stripTerminalSequences(this.getSelectionSourceLine(point));
const segments: Array<{ start: number; end: number; selectable: boolean; joiner: boolean }> = [];
let start = 0;
for (const segment of wordSegmenter.segment(line)) {
const end = start + visibleWidth(segment.segment);
if (point.col >= start && point.col < end) {
return {
start: { ...point, col: start },
end: { ...point, col: end, boundary: true },
};
}
const joiner = TERMINAL_WORD_SELECTION_JOINERS.has(segment.segment);
segments.push({ start, end, selectable: segment.isWordLike === true || joiner, joiner });
start = end;
}
return undefined;
const clickedSegmentIndex = segments.findIndex(
(segment) => point.col >= segment.start && point.col < segment.end,
);
if (clickedSegmentIndex < 0) return undefined;
const canJoin = (
left: { selectable: boolean; joiner: boolean },
right: { selectable: boolean; joiner: boolean },
): boolean => left.selectable && right.selectable && (left.joiner || right.joiner);
let selectionStart = segments[clickedSegmentIndex].start;
let selectionEnd = segments[clickedSegmentIndex].end;
for (let index = clickedSegmentIndex; index > 0 && canJoin(segments[index - 1], segments[index]); index--) {
selectionStart = segments[index - 1].start;
}
for (
let index = clickedSegmentIndex;
index < segments.length - 1 && canJoin(segments[index], segments[index + 1]);
index++
) {
selectionEnd = segments[index + 1].end;
}
return {
start: { ...point, col: selectionStart },
end: { ...point, col: selectionEnd, boundary: true },
};
}
private getLineSelection(point: SelectionPoint): SelectionRange {

View file

@ -1039,6 +1039,35 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("coalesces slash and hyphen separated segments for double-click word selection", async () => {
for (const { line, needle } of [
{ line: "extensions/starline/fixed-editor/compositor.ts", needle: "starline" },
{ line: "earendil-works/pi-tui", needle: "works" },
]) {
const copied: string[] = [];
const terminal = new RecordingTerminal(80, 1);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
tui.addChild(new Text(line, 0, 0));
tui.start();
await terminal.waitForRender();
const oneBasedClickColumn = line.indexOf(needle) + 1;
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`);
await terminal.waitForRender();
assert.deepStrictEqual(copied, [line]);
tui.stop();
}
});
it("highlights a complete whitespace segment during a word drag", async () => {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);