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:
@@ -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('')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user