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

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('')
}
/**

View File

@@ -184,6 +184,24 @@ describe('parseAnsiLines: backspaces', () => {
])
})
it('steps over an SGR sequence instead of erasing its bytes', () => {
// `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
// corrupt it and repaint the rest of the line with whatever the remainder
// parses as. The visible result is `aXY`, still red, with the reset intact.
expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
{ text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
{ text: 'XY', style: undefined },
]])
})
it('erases across a style boundary without dropping the styles between', () => {
// The backspace reaches back past the reset to the last printed character.
expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
{ text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
{ text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
]])
})
it('applies the overwrite after a carriage-return redraw, not before', () => {
// The redraw wins first; the backspace then erases inside what survived.
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined })