Parcourir la source

fix(web): bound the SGR state and follow real terminal widths

Nine findings, one critical. Terminal cases verified in a real terminal first.

CRITICAL: cells held the accumulated SGR history, so every state boundary
re-emitted the whole chain — output switching color without a full reset
emitted O(n^2) characters. Measured: 3200 such cells produced 25 MB, and the
reviewer's ~90 KB alternating-color case is well under bash's own output cap.
State is now a normalized record (foreground, background, attribute set) with
one canonical sequence per boundary, so the emitted text is linear in cells;
the 90 KB case parses in 36 ms. That also makes the attribute closers every
chalk-based tool writes actually close: `\x1b[1mbold\x1b[22mplain` leaves the
following write PLAIN, which a real terminal confirms.

Width follows emoji presentation, not the U+2600-U+27BF block: `A✓B` redrawn
with `XY` shows `XYB`, so the check every progress line writes is ONE column.
Taking the block as wide misaligned exactly the output this card exists for.

Writing over either half of a wide pair blanks the other, since a terminal
cannot leave one cell of a two-cell glyph standing.

`line\n\x1b[0m` does not end in a newline as a string yet its last parsed line
holds nothing visible, so the terminator check now reads the parsed lines — it
had added a blank row and inflated the collapse count.

A line with no cursor movement no longer builds a column buffer at all; only
its SGR is folded, so an `ls -R` or a 5k-line log allocates nothing per
character.

The `.terminalDescription` rule had been inserted into an existing grouped
selector, silently giving `.codeBody` description typography and changing its
bottom margin from 4px to 0 — a pre-existing surface this PR does not own.
Split out, `.codeBody`'s margin restored.

Three comments contradicted their code: the fixture's exit-marker line (still
claiming recovery from a marker deliberately removed), `bash-sample`'s header
(still routing a click to the details panel, and calling the consumer's cap the
block's own), and a DetailsPanel comment stacked above the wrong rule. The
ui-primitives README documented only the CR/BS half of the replay, so a reader
would expect `OK0%` where `100%\r\x1b[KOK` renders `OK`.
Chinesezjc il y a 1 mois
Parent
commit
fdcb45b619

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
-2026-07-28-web-terminal-card.md: f580e3c8c17b8701ae223ea3ed910fa57b915278
-2026-07-28-web-terminal-card.zh.md: eb48fd3a1bcff9599b138630113460fe867a6aef
+2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
+2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md


+ 4 - 2
packages/client/connection/src/client/fixture.ts

@@ -284,8 +284,10 @@ function buildAlphaLog(): SessionEvent[] {
   ]
   // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
   // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
-  // whose prompt label is its last segment, and a non-zero exit recovered from
-  // the trailing marker the bash tool appends. Named `bash`, so it also covers
+  // whose prompt label is its last segment, and a non-zero exit authored beside
+  // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
+  // `[exit code: N]` marker, since the real presenter consumes that one out of
+  // the body. Named `bash`, so it also covers
   // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
   // row) — the two chat-row shapes the terminal card renders in.
   //

+ 8 - 6
packages/client/ui-conversation/src/client/chat/ToolRow.module.css

@@ -182,14 +182,16 @@ button.leading {
    also replaces each primitive's own standalone vertical spacing with the
    flow's row rhythm. */
 .codeBody,
-/* Indented to the terminal body's own column, so the description reads as the
-   card's heading rather than as another summary row. */
+.terminalBody {
+  margin: 4px 0 4px 22px;
+}
+
+/* Indented to the body's own column so the description reads as the card's
+   heading rather than as another summary row, and sits tight against the card
+   below it. Its own rule: grouping it with a body would put description
+   typography on a `CodeBlock` wrapper and change that body's spacing. */
 .terminalDescription {
   margin: 4px 0 0 22px;
   color: var(--dsw-alias-label-secondary);
   font: var(--dsw-font-xs-13);
 }
-
-.terminalBody {
-  margin: 4px 0 4px 22px;
-}

+ 2 - 2
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css

@@ -93,8 +93,6 @@
   color: var(--dsw-alias-state-error-primary);
 }
 
-/* The terminal card sits directly under its section label, so it drops the
-   primitive's standalone vertical margin; the section owns the spacing. */
 /* Above the card, which is where the render-intent contract puts a terminal
    call's description; the panel has no summary row to carry it. */
 .terminalDescription {
@@ -103,6 +101,8 @@
   font: var(--dsw-font-xs-13);
 }
 
+/* The terminal card sits directly under its section label, so it drops the
+   primitive's standalone vertical margin; the section owns the spacing. */
 .terminal {
   margin: 0;
 }

+ 6 - 4
packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx

@@ -6,10 +6,12 @@
 //
 // A bash call declares the terminal render intent, so this row also renders
 // the command's own output through TerminalBlock. This row has no expand
-// control (a click goes to the details panel), so its terminal body is
-// resident rather than expand-gated as in ToolRow; the block's own height cap
-// (CHAT_TERMINAL_MAX_LINES) and internal expander keep a long output from
-// taking over the message flow.
+// control and is not a details-panel target either (tool rows stopped being
+// one), so its terminal body is resident rather than expand-gated as in
+// ToolRow, and the card's own copy and expand controls are the row's only
+// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
+// flow's tighter cap over the block's own default of 16 — and the block's
+// internal expander keeps a long output from taking over the message flow.
 
 import type { Context } from 'cordis'
 import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: fda45aaea8e6ad640c0002cf34e654d49e63582a
-README.zh.md: 94e99b838e102266fb3b5868d3ceb06ae0633b15
+README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
+README.zh.md: 51d3c7a3f3dd46e487a3031cfa1f3d93bace336a

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
packages/client/ui-primitives/README.md


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
packages/client/ui-primitives/README.zh.md


+ 13 - 3
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -126,9 +126,19 @@ export function TerminalBlock({
 }: TerminalBlockProps) {
   const text = output ?? ''
   // A command's output ends with a newline; that terminator is not an extra
-  // blank line to draw or to count against the height cap. The copy control
-  // still copies `text` untouched.
-  const lines = useMemo(() => parseAnsiLines(text.endsWith('\n') ? text.slice(0, -1) : text), [text])
+  // blank line to draw or to count against the height cap. The check runs on the
+  // PARSED lines rather than on the raw text, because a reset after the final
+  // newline (`line\n\x1b[0m`) leaves the string not ending in one while still
+  // producing a last line with nothing visible in it. A genuinely blank final
+  // line — the double newline — survives, since it has a real empty line before
+  // the terminator. The copy control still copies `text` untouched.
+  const lines = useMemo(() => {
+    const parsed = parseAnsiLines(text)
+    const last = parsed[parsed.length - 1]
+    const terminated = parsed.length > 1 && last !== undefined
+      && last.every(span => span.text === '')
+    return terminated ? parsed.slice(0, -1) : parsed
+  }, [text])
   const [expanded, setExpanded] = useState(false)
   const [copied, setCopied] = useState(false)
 

+ 153 - 39
packages/client/ui-primitives/src/ansi.ts

@@ -95,6 +95,9 @@ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
  */
 const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
 
+/** SGR sequences alone, for folding state through a line that needs no replay. */
+const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
+
 /** Terminal tab stop width; a tab advances to the next multiple of this. */
 const TAB_WIDTH = 8
 
@@ -107,12 +110,18 @@ const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
 
 /**
  * Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
- * CJK punctuation, and the emoji/symbol blocks a command's output realistically
- * carries.
+ * CJK punctuation, and characters with emoji presentation. Text-presentation
+ * symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
+ * must stay out of this set.
  */
 const WIDE_CHAR = new RegExp(
   '\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
-  + '|[\\u{1f300}-\\u{1faff}\\u{2600}-\\u{27bf}\\uff01-\\uff60\\u3000-\\u303e]',
+  // Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
+  // width — `\u2713` (the check every progress line writes, this fixture
+  // included) advances one column, verified against a real terminal, so taking
+  // the whole block as wide misaligned exactly the output this card exists for.
+  + '|\\p{Emoji_Presentation}'
+  + '|[\\uff01-\\uff60\\u3000-\\u303e]',
   'u',
 )
 
@@ -129,6 +138,87 @@ function isWide(char: string): boolean {
   return WIDE_CHAR.test(char)
 }
 
+/**
+ * A cell's graphic state, normalized. Held as fields rather than as the raw
+ * sequence history because a terminal tracks CURRENT state, not a transcript:
+ * accumulating sequences made each state boundary re-emit the whole chain, so
+ * output that switches color without a full reset emitted O(n^2) characters
+ * (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
+ * the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
+ * `24`, `27`, `29` — actually close their attribute instead of appending to it.
+ */
+interface SgrState {
+  fg: string
+  bg: string
+  /** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
+  attrs: readonly string[]
+}
+
+/** The default state: no color, no attributes. */
+const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
+
+/** Attribute closers, mapped to the opener parameters each one turns off. */
+const ATTR_CLOSERS: Record<string, readonly string[]> = {
+  22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
+}
+
+/**
+ * Fold one SGR sequence's parameters into the state it produces.
+ * @param state - state in force before the sequence.
+ * @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
+ * @returns the state the sequence leaves in force.
+ */
+function foldSgr(state: SgrState, params: string): SgrState {
+  const codes = params === '' ? ['0'] : params.split(';')
+  let next = state
+  for (let index = 0; index < codes.length; index++) {
+    const code = String(codes[index])
+    if (code === '' || code === '0') { next = SGR_NONE; continue }
+    // Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
+    // consume their own arguments, so they are taken whole.
+    if (code === '38' || code === '48') {
+      const kind = codes[index + 1] ?? ''
+      const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
+      const value = codes.slice(index, index + span + 1).join(';')
+      next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
+      index += span
+      continue
+    }
+    const closes = ATTR_CLOSERS[code]
+    if (closes !== undefined) {
+      next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
+      continue
+    }
+    const numeric = Number(code)
+    if (code === '39') { next = { ...next, fg: '' }; continue }
+    if (code === '49') { next = { ...next, bg: '' }; continue }
+    if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
+    if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
+    if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
+  }
+  return next
+}
+
+/**
+ * Render a state as the one canonical sequence that establishes it from the
+ * default, so a boundary emits a bounded string no matter how the state was
+ * reached.
+ * @param state - the state to open.
+ * @returns the SGR sequence, or the empty string for the default state.
+ */
+function openSgr(state: SgrState): string {
+  const codes = [...state.attrs]
+  if (state.fg !== '') codes.push(state.fg)
+  if (state.bg !== '') codes.push(state.bg)
+  return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
+}
+
+/** Whether two states are the same, so a boundary is only emitted on a change. */
+function sameSgr(a: SgrState, b: SgrState): boolean {
+  return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
+    && a.attrs.every((attr, index) => attr === b.attrs[index])
+}
+
 /**
  * Replay one line's cursor movements the way a terminal paints it, into a
  * column buffer. Carriage return and backspace only MOVE the cursor — neither
@@ -149,19 +239,29 @@ function isWide(char: string): boolean {
  * @returns the line as the terminal would have it after every movement, plus the
  *   SGR state at its end for the next line to enter with.
  */
-function replayLine(line: string, entrySgr: string): { text: string; sgr: string } {
+function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
   // Same shape anser splits on, so a sequence is one unit here as well.
   const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
-  /** Per column: the SGR state in force when it was written, and its character. */
-  const columns: ({ sgr: string; char: string; spacer?: boolean } | undefined)[] = []
+  /** Per column: the state in force when it was written, and its character. */
+  const columns: (Cell | undefined)[] = []
   let cursor = 0
-  // SGR state accumulates as the line is scanned, exactly as a terminal tracks
-  // it: each cell is stamped with whatever was in force at the moment of the
-  // write, so a later redraw cannot restyle the cells it does not reach. It
-  // enters carrying the previous line's state, since a newline does not reset it.
+  // State is tracked exactly as a terminal tracks it: each cell is stamped with
+  // whatever was in force at the moment of the write, so a later redraw cannot
+  // restyle the cells it does not reach. It enters carrying the previous line's
+  // state, since a newline does not reset it.
   let sgr = entrySgr
   let at = 0
 
+  /** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
+  const clear = (index: number, fill: string): void => {
+    const cell = columns[index]
+    if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
+    else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
+      columns[index + 1] = { sgr, char: fill }
+    }
+    columns[index] = { sgr, char: fill }
+  }
+
   const consume = (chunk: string): void => {
     for (const char of chunk) {
       if (char === '\r') { cursor = 0; continue }
@@ -176,13 +276,16 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
       }
       if (ZERO_WIDTH.test(char)) {
         // No column of its own: it attaches to the cell already written, so a
-        // redraw that covers that cell covers the mark with it.
-        // With no cell to attach to (line start, or straight after a redraw to
-        // column 0) a terminal shows nothing rather than a lone accent.
+        // redraw that covers that cell covers the mark with it. With no cell to
+        // attach to (line start, or straight after a redraw to column 0) a
+        // terminal shows nothing rather than a lone accent.
         const base = cursor > 0 ? columns[cursor - 1] : undefined
         if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
         continue
       }
+      // Writing over either half of a wide pair blanks the other half, since a
+      // terminal cannot leave one cell of a two-cell glyph standing.
+      clear(cursor, ' ')
       columns[cursor] = { sgr, char }
       cursor++
       // A wide character occupies two columns; the trailing one is a spacer,
@@ -205,31 +308,31 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
       // standing, which is text the terminal never showed. `1` blanks from the
       // line start THROUGH the cursor column (inclusive, per the CSI spec)
       // rather than dropping those cells, since the cursor does not move and a
-      // later write can still land past them.
-      // Only the FIRST parameter selects the mode; a terminal ignores the rest
-      // (`1;2K` erases exactly as `1K` does — verified against a real terminal).
+      // later write can still land past them. Only the FIRST parameter selects
+      // the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
       const mode = String(params.split(';')[0])
-      if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' }
+      if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
       else columns.length = mode === '2' ? 0 : cursor
       continue
     }
     // Only SGR carries graphic state; every other final byte is a cursor or
-    // erase action that must not be accumulated into a cell's style.
+    // erase action that must not affect a cell's style.
     if (final !== 'm') continue
-    sgr = /^0?$/.test(params) ? '' : sgr + match[0]
+    sgr = foldSgr(sgr, params)
   }
   consume(line.slice(at))
 
-  // Re-emit the columns, opening a run only where its SGR state changes, so
-  // anser sees the same styling a terminal shows. A `\x1b[2K` can leave holes
-  // before the cursor, which a terminal paints as blanks.
+  // Re-emit the columns, opening a run only where its state changes, so anser
+  // sees the same styling a terminal shows. Each boundary emits ONE canonical
+  // sequence for the state it opens, which is what keeps the output linear in
+  // the number of cells however the state was reached.
   let out = ''
   let active = entrySgr
   for (let index = 0; index < columns.length; index++) {
-    const column = columns[index] ?? { sgr: '', char: ' ' }
-    if (column.sgr !== active) {
-      if (active !== '') out += '\u001b[0m'
-      out += column.sgr
+    const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
+    if (!sameSgr(column.sgr, active)) {
+      if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
+      out += openSgr(column.sgr)
       active = column.sgr
     }
     // A spacer still holds its column. While its lead cell survives, the wide
@@ -243,13 +346,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
   // sequence after the final write (the `\x1b[0m` closing a colored line) changes
   // no cell yet still ends the run, and it has to reach both the DOM and the
   // next line. Without this a line ending in a reset leaked its color onward.
-  if (active !== sgr) {
-    if (active !== '') out += '\u001b[0m'
-    out += sgr
+  if (!sameSgr(active, sgr)) {
+    if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
+    out += openSgr(sgr)
   }
   return { text: out, sgr }
 }
 
+/** One replayed column: the state it was written with, and its character. */
+interface Cell {
+  sgr: SgrState
+  char: string
+  /** The trailing half of a wide character's two-column pair. */
+  spacer?: boolean
+}
+
 /**
  * Replay every line's cursor movements. A `\r` that only terminates a CRLF line
  * is dropped first, so those lines keep their text instead of being redrawn onto
@@ -260,18 +371,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
  */
 function applyCursorMovements(text: string): string {
   const replayed: string[] = []
-  let sgr = ''
+  let sgr = SGR_NONE
   for (const raw of text.split('\n')) {
     const line = raw.replace(/\r+$/, '')
-    // A line with no cursor movement or erase needs no replay — its tabs stay
-    // literal for `white-space: pre` to lay out — but its own SGR still has to
-    // be tracked so a later line that DOES replay enters with the right state.
-    // Tabs only need column arithmetic where a redraw can land on them, which is
-    // exactly the replayed case. An erase counts: `\x1b[1K` blanks columns even
-    // with no `\r` beside it.
-    const result = replayLine(line, sgr)
-    replayed.push(NEEDS_REPLAY.test(line) ? result.text : line)
-    sgr = result.sgr
+    if (NEEDS_REPLAY.test(line)) {
+      const result = replayLine(line, sgr)
+      replayed.push(result.text)
+      sgr = result.sgr
+      continue
+    }
+    // No cursor movement: the line needs no column buffer, and painting one
+    // would allocate a cell per character of output this card never redraws —
+    // an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
+    // line that DOES replay enters with the right state.
+    replayed.push(line)
+    for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
   }
   return replayed.join('\n')
 }

+ 69 - 0
packages/client/ui-primitives/tests/ansi.spec.ts

@@ -357,6 +357,75 @@ describe('parseAnsiLines: line-end state and column widths', () => {
   })
 })
 
+describe('parseAnsiLines: bounded state and true widths', () => {
+  it('emits one canonical sequence per boundary however the state was reached', () => {
+    // Colors that never fully reset used to accumulate raw sequence history per
+    // cell, so every boundary re-emitted the whole chain: 3200 such cells
+    // produced 25 MB and eventually a RangeError. The state is normalized now,
+    // so the emitted text stays linear in the number of cells.
+    let input = ''
+    for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
+    const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
+    expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
+  })
+
+  it('closes an attribute with its closer instead of appending to the state', () => {
+    // `1` then `22` is bold then not-bold, which every chalk-based tool writes;
+    // appending both left the cell bold and grew the chain.
+    // Verified in a real terminal: the `22` closes the bold, so the `x` written
+    // after the redraw is PLAIN. Appending both left it bold and grew the chain.
+    expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
+      { text: 'x', style: undefined },
+    ]])
+    expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
+      { text: 'A', style: { fontWeight: 700 } },
+      { text: 'B', style: undefined },
+    ]])
+  })
+
+  it('folds extended colors, backgrounds and every attribute closer', () => {
+    // The 256-palette and truecolor forms consume their own arguments, so the
+    // fold has to take them whole rather than as separate codes.
+    expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'rgb(255, 135, 0)' } },
+    ]])
+    expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'rgb(10, 20, 30)' } },
+    ]])
+    // A background survives the same way, and `49` closes it.
+    expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: undefined },
+    ]])
+    // Each closer drops only its own attribute: `4` underline closed by `24`
+    // while the italic opened before it stays in force.
+    expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: { fontStyle: 'italic' } },
+    ]])
+    // `39` closes a foreground without touching the background.
+    expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
+    ]])
+  })
+
+  it('treats a text-presentation symbol as one column', () => {
+    // Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
+    // check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
+    // misaligned exactly the progress output this card exists to show.
+    expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
+    // An emoji-presentation character is two, so the same redraw leaves a blank.
+    expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
+  })
+
+  it('blanks both halves of a wide pair when either is overwritten', () => {
+    // A terminal cannot leave one cell of a two-cell glyph standing, so writing
+    // over the spacer clears the lead as well.
+    // Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
+    // the second glyph — writing the lead cell blanks its spacer, so the column
+    // stays occupied rather than collapsing.
+    expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
+  })
+})
+
 describe('parseAnsiLines: SGR across lines', () => {
   it('carries active state past a newline, as a terminal does', () => {
     // Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.

+ 9 - 0
packages/client/ui-primitives/tests/terminal-block.spec.tsx

@@ -146,6 +146,15 @@ describe('TerminalBlock states', () => {
     expect(outputLines(view.container)).toEqual(['a', 'b'])
   })
 
+  it('drops the output terminator even when a reset follows the final newline', () => {
+    // `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed
+    // line holds nothing visible — a common shape, since tools close their color
+    // after the last line. Judging the terminator on the raw text added a blank
+    // row and inflated both the card height and the collapse count.
+    const view = render(<TerminalBlock command="ls" output={`a\nb\n${ESC}[0m`} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b'])
+  })
+
   it('keeps a genuinely blank final line when the output ends with two newlines', () => {
     const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
     expect(outputLines(view.container)).toEqual(['a', 'b', ''])

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff