fix(web): keep escapes, UNC roots, and truncated cwd honest

Four review findings. Two are defects the previous two rounds introduced,
which the existing tests did not catch:

A backspace erased raw bytes, so one landing after an SGR reset ate part of
the escape: `\x1b[31mabc\x1b[0m\b\bXY` left `\x1b[` and repainted the rest of
the line with whatever the remainder parsed as. Backspaces now resolve over
VISIBLE characters — a CSI sequence is one indivisible unit a backspace steps
over on its way to the last printed character, so the surviving text keeps
the color its run authored.

The cwd normalizer popped a UNC share root: `\\server\share` with a `..`
became `/server`, losing the separators too. A UNC path's server and share
are its root, and Windows cannot climb above a share, so they are split off
and the remainder collapses against that root.

The other two are gaps the earlier fixes left:

The render-site fallback row still passed the args-derived summary, so any
terminal-declaring tool without its own keyed row (`terminal_send`) lost the
contract's above-card description. It now prefers the description exactly as
BashRow does.

A settled call read `call?.cwd`, which cannot tell "the call omitted a cwd"
from "the paging window dropped the call head". The second case has no cwd
anywhere and the original call may have used an explicit workdir, so it now
draws a bare `$` instead of naming the session workspace.
This commit is contained in:
Chinesezjc
2026-07-29 13:10:44 +08:00
parent 0f70886e0c
commit bbe1481a9e
8 changed files with 150 additions and 25 deletions
+42 -7
View File
@@ -114,14 +114,49 @@ function applyCarriageReturns(text: string): string {
*/
function applyBackspaces(text: string): string {
if (!text.includes('\u0008')) return text
return text.split('\n').map((line) => {
const kept: string[] = []
for (const char of line) {
if (char === '\u0008') kept.pop()
else kept.push(char)
return text.split('\n').map(applyBackspacesToLine).join('\n')
}
/**
* One line's backspaces, resolved over VISIBLE characters only. A CSI sequence
* moves no cursor, so it must survive intact: erasing its bytes would corrupt
* the sequence and repaint the rest of the output with whatever the mangled
* remainder parses as. The sequences are therefore held as indivisible units
* that a backspace steps over on its way to the last printed character, and a
* unit already erased stays erased so a run's own color still applies to what
* remains of it.
* @param line - one output line, still carrying its CSI sequences.
* @returns the line with each backspace applied to the character before it.
*/
function applyBackspacesToLine(line: string): string {
if (!line.includes('\u0008')) return line
const units: { text: string; visible: boolean }[] = []
// Same shape anser splits on: CSI ... final byte. Matched here so a sequence
// is one unit rather than a run of erasable characters.
const csi = /\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*[\u0040-\u007e]/g
let at = 0
for (const match of line.matchAll(csi)) {
for (const char of line.slice(at, match.index)) units.push({ text: char, visible: true })
units.push({ text: match[0], visible: false })
at = match.index + match[0].length
}
for (const char of line.slice(at)) units.push({ text: char, visible: true })
const kept: { text: string; visible: boolean }[] = []
for (const unit of units) {
if (unit.visible && unit.text === '\u0008') {
// Walk back past any escapes to the last printed character and drop it,
// keeping those escapes so the surviving text stays styled as authored.
for (let index = kept.length - 1; index >= 0; index--) {
if (kept[index]?.visible !== true) continue
kept.splice(index, 1)
break
}
continue
}
return kept.join('')
}).join('\n')
kept.push(unit)
}
return kept.map(unit => unit.text).join('')
}
/**