dsh-code 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -160,10 +160,12 @@ import {
160
160
  type StatusTone,
161
161
  } from './render/status.ts'
162
162
  import { displayTail, displayText, padColumns, singleLineText, truncateColumns } from './render/text.ts'
163
+ import { advanceTranscriptViewport, visibleTranscriptRows } from './render/transcript-viewport.ts'
163
164
  import {
164
165
  clampScroll,
165
166
  followInspectorCursor,
166
167
  inspectorViewport,
168
+ inspectableTranscriptEntries,
167
169
  layoutGutterRows,
168
170
  liveRegionBudget,
169
171
  moveScroll,
@@ -179,6 +181,7 @@ import {
179
181
  styledLines,
180
182
  textLines,
181
183
  transcriptEntryLines,
184
+ type StyledLine,
182
185
  } from './render/lines.ts'
183
186
  import {
184
187
  composerMaxRows,
@@ -362,8 +365,8 @@ export interface AppProps {
362
365
  saveLanguage: (name: LanguageName) => void
363
366
  /** Apply and persist one /theme selection; the runner owns the theme.json file. */
364
367
  saveTheme?: (name: ThemeName) => void
365
- /** Whether timed animations run at startup (animations.json; on by default
366
- * like parseAnimationsPref, only an explicit false disables them). */
368
+ /** Whether decorative animations run at startup (animations.json; on by
369
+ * default). Functional activity indicators remain live when disabled. */
367
370
  animations?: boolean
368
371
  /** Apply and persist one /animation toggle; the runner owns the file. */
369
372
  saveAnimations?: (enabled: boolean) => void
@@ -377,24 +380,23 @@ export interface AppProps {
377
380
  applyEditorKeys: () => Promise<string>
378
381
  }
379
382
 
380
- /**
381
- * The original web StateDot chase used by the busy composer marker. With
382
- * animations off it freezes on the first frame (still visibly busy).
383
- */
384
- function Caret({ animated = true }: { animated?: boolean }): ReactElement {
385
- const tick = useFrames(CARET_BLINK_TICK_MS, animated)
383
+ /** Functional streaming caret: activity remains visible in reduced motion. */
384
+ function Caret(): ReactElement {
385
+ const tick = useFrames(CARET_BLINK_TICK_MS, true)
386
386
  return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
387
387
  }
388
388
 
389
389
  /** One resettable input-caret phase shared by the entire composer. */
390
- function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
391
- const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
390
+ function ShimmerLine({ text, themeFlowAnimated = true }: { text: string; themeFlowAnimated?: boolean }): ReactElement {
391
+ // Shimmer is functional activity feedback and always advances. The setting
392
+ // only gates decorative movement through a flowing theme's anchor colors.
393
+ const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, true)
392
394
  const palette = getPalette()
393
395
  // Flowing themes walk their anchors for the shimmer highlight so
394
396
  // streaming text glows along the spectrum; other themes keep the bright
395
397
  // accent.
396
398
  const flow = themeFlow()
397
- const highlight = flow !== undefined && animated
399
+ const highlight = flow !== undefined && themeFlowAnimated
398
400
  ? flowColor(tick * DEEP_DIVING_SHIMMER_TICK_MS + flow.phaseMs, flow.anchors)
399
401
  : palette.brandBright
400
402
  const graphemes = splitGraphemes(text)
@@ -407,11 +409,9 @@ function ShimmerLine({ text, animated = true }: { text: string; animated?: boole
407
409
  Text,
408
410
  {
409
411
  key: `${grapheme.start}-${grapheme.end}`,
410
- color: inkColor(!animated
411
- ? (sparkle ? palette.brandBright : palette.brandDeep)
412
- : sparkle
413
- ? deepDivingSparkColor(tick, palette.brandDeep, highlight)
414
- : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
412
+ color: inkColor(sparkle
413
+ ? deepDivingSparkColor(tick, palette.brandDeep, highlight)
414
+ : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
415
415
  bold: sparkle || undefined,
416
416
  },
417
417
  grapheme.text,
@@ -426,10 +426,14 @@ function ShimmerLine({ text, animated = true }: { text: string; animated?: boole
426
426
  * only once the turn has clearly been running (15s) — anchored to `turn/start`
427
427
  * so a resumed mid-turn keeps the real time.
428
428
  */
429
- function DeepDivingLine({ since, animated = true }: { since: number; animated?: boolean }): ReactElement {
429
+ function DeepDivingLine({ since, themeFlowAnimated = true }: { since: number; themeFlowAnimated?: boolean }): ReactElement {
430
+ // Elapsed time is functional progress, not decoration. Own a low-rate tick
431
+ // so the clock advances independently of transcript/store updates.
432
+ const elapsedTick = useFrames(1_000, true)
433
+ void elapsedTick
430
434
  const elapsed = since === 0 ? 0 : Date.now() - since
431
435
  const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
432
- return createElement(ShimmerLine, { text, animated })
436
+ return createElement(ShimmerLine, { text, themeFlowAnimated })
433
437
  }
434
438
 
435
439
  /**
@@ -451,6 +455,32 @@ export function streamTailBodyColumns(rowColumns: number, prefix: string, contin
451
455
  return Math.max(1, width - prefixColumns)
452
456
  }
453
457
 
458
+ interface StreamTailLayout {
459
+ readonly text: string
460
+ readonly truncated: boolean
461
+ readonly rows: number
462
+ }
463
+
464
+ /** Shared physical layout for streaming render and viewport row accounting. */
465
+ function streamTailLayout(text: string, columns: number, maxRows: number, prefix: string, continuationPrefix: string): StreamTailLayout {
466
+ const safeRows = Math.max(1, maxRows)
467
+ const contentColumns = streamTailBodyColumns(columns, prefix, continuationPrefix)
468
+ const initial = displayTail(text, contentColumns, safeRows)
469
+ const tail = initial.truncated && safeRows > 1
470
+ ? displayTail(text, contentColumns, safeRows - 1)
471
+ : initial
472
+ return {
473
+ text: tail.text,
474
+ truncated: tail.truncated,
475
+ rows: tail.text.split('\n').length + (tail.truncated && safeRows > 1 ? 1 : 0),
476
+ }
477
+ }
478
+
479
+ function streamTailPhysicalRows(text: string, columns: number, maxRows: number, prefix = '', continuationPrefix = prefix): number {
480
+ if (text === '' || maxRows <= 0) return 0
481
+ return streamTailLayout(text, columns, maxRows, prefix, continuationPrefix).rows
482
+ }
483
+
454
484
  function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children, columns }: {
455
485
  text: string
456
486
  dim: boolean
@@ -463,14 +493,8 @@ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = pref
463
493
  }): ReactElement {
464
494
  const safeRows = Math.max(1, maxRows)
465
495
  // Both prefixes participate because every physical row repeats its hanging
466
- // indent. The wrap matches settled markdown (row width minus prefix), so
467
- // the flush at turn end does not reflow the last paragraph.
468
- const contentColumns = streamTailBodyColumns(columns, prefix, continuationPrefix)
469
- const initial = displayTail(text, contentColumns, safeRows)
470
- // Reserve one row for the omission marker only when a marker is needed.
471
- const tail = initial.truncated && safeRows > 1
472
- ? displayTail(text, contentColumns, safeRows - 1)
473
- : initial
496
+ // indent. Rendering and viewport accounting consume the exact same layout.
497
+ const tail = streamTailLayout(text, columns, safeRows, prefix, continuationPrefix)
474
498
  const rows = tail.text.split('\n')
475
499
  return createElement(
476
500
  Box,
@@ -1066,7 +1090,9 @@ function StatusLine({ facts, stats, busy, columns, items, onRows, animated }: {
1066
1090
  const row2Present = layout.row2.left.length > 0
1067
1091
  return createElement(
1068
1092
  Box,
1069
- { flexDirection: 'column' },
1093
+ // Do not stretch back to the parent width: VS Code autowraps a painted
1094
+ // row at exactly stdout.columns, creating one unreported physical row.
1095
+ { flexDirection: 'column', width: columns },
1070
1096
  renderRow(layout.row1, 's1'),
1071
1097
  row2Present ? renderRow(layout.row2, 's2', STATUS_ROW2_INDENT) : undefined,
1072
1098
  )
@@ -1379,16 +1405,18 @@ function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
1379
1405
 
1380
1406
  const MemoStaticTranscript = memo(StaticTranscript)
1381
1407
 
1408
+ interface SettledPhysicalRow {
1409
+ /** Stable one-row Static element. */
1410
+ readonly element: ReactElement
1411
+ /** The same row model reused by the mutable viewport tail. */
1412
+ readonly line: StyledLine
1413
+ }
1414
+
1382
1415
  interface SettledRowRecord {
1383
- /** The row Box element (keyed by the entry's settled index). */
1384
- box: ReactElement
1385
- /** The roomy-prompt spacer BEFORE the row, or undefined. */
1386
- before: ReactElement | undefined
1387
- /** The roomy-prompt spacer AFTER the row, or undefined. */
1388
- after: ReactElement | undefined
1389
- /** Physical rows this record contributes (row body plus spacers) — the
1390
- * unit of the rendered-history cap. */
1391
- rows: number
1416
+ /** Physical rows for one settled entry, including roomy-prompt spacers. */
1417
+ readonly physical: readonly SettledPhysicalRow[]
1418
+ /** Physical rows this record contributes — the rendered-history-cap unit. */
1419
+ readonly rows: number
1392
1420
  }
1393
1421
 
1394
1422
  /** The incremental settled-history cache (see `computeSettledRows`). */
@@ -1409,10 +1437,13 @@ interface SettledRowsCache {
1409
1437
  epoch: number
1410
1438
  /** The terminal width the rows were wrapped for; a change forces a rebuild. */
1411
1439
  columns: number
1412
- /** The flat row list (header + optional hint + per-entry before/box/after). */
1440
+ /** Header/hint followed by one element per physical transcript row. */
1413
1441
  flat: ReactElement[]
1442
+ /** Physical transcript rows only (header/hint excluded). */
1443
+ physical: SettledPhysicalRow[]
1414
1444
  /** Settled entries dropped from the window's head (rendering only — the
1415
- * event log keeps everything; Ctrl+O and /export read it directly). */
1445
+ * event log keeps everything; /export reads all of it and Ctrl+O reads its
1446
+ * inspectable entries). */
1416
1447
  droppedEntries: number
1417
1448
  /** Physical rows the window's entries contribute (excludes header/hint). */
1418
1449
  totalRows: number
@@ -1429,25 +1460,29 @@ interface SettledRowsResult {
1429
1460
  built: number
1430
1461
  }
1431
1462
 
1432
- /** Build one settled row (row Box plus its roomy-prompt spacers and row count). */
1463
+ /** Build one settled entry as stable physical rows shared by Static and viewport. */
1433
1464
  function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean, columns: number): SettledRowRecord {
1434
- // The SAME physical-row pipeline as the live tail (settledEntryLines).
1435
1465
  // Every row carries its own two-column prefix (user ❯, reply body, tool
1436
- // cards), which is the whole gutter: no extra container padding, so reply
1437
- // text starts at the same column as the composer's input text and wrapped
1438
- // continuations keep their hanging indent instead of resetting to column 0.
1439
- const roomyPrompt = entry.kind === 'user' && !entry.notice
1466
+ // cards), which is the whole gutter. Physical row identity—not entry
1467
+ // identity—is the viewport currency, so an oversized entry can split cleanly
1468
+ // between native scrollback and the bottom-anchored live tail.
1440
1469
  const lines = settledEntryLines(entry, Math.max(10, columns - 2), showReasoning)
1441
- return {
1442
- box: createElement(Box, { key: index }, createElement(StyledRows, { lines })),
1443
- before: roomyPrompt
1444
- ? createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
1445
- : undefined,
1446
- after: roomyPrompt
1447
- ? createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
1448
- : undefined,
1449
- rows: lines.length + (roomyPrompt ? 2 : 0),
1470
+ const physical: SettledPhysicalRow[] = lines.map((line, row) => ({
1471
+ line,
1472
+ element: createElement(Box, { key: `entry-${index}-row-${row}` }, createElement(StyledRows, { lines: [line] })),
1473
+ }))
1474
+ if (entry.kind === 'user' && !entry.notice) {
1475
+ const blank: StyledLine = { segments: [] }
1476
+ physical.unshift({
1477
+ line: blank,
1478
+ element: createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')),
1479
+ })
1480
+ physical.push({
1481
+ line: blank,
1482
+ element: createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')),
1483
+ })
1450
1484
  }
1485
+ return { physical, rows: physical.length }
1451
1486
  }
1452
1487
 
1453
1488
  /** The dim hint row placed under the header once the window has dropped entries. */
@@ -1474,8 +1509,9 @@ function settledTrimHint(droppedEntries: number, columns: number): ReactElement
1474
1509
  *
1475
1510
  * RENDERED-HISTORY CAP: the window holds at most `rowCap` physical rows of
1476
1511
  * settled transcript (header and hint reserved on top). The cap exists only
1477
- * here — the event log, the store projection, /export, Ctrl+O, and /resume
1478
- * keep the full history. Ink 5's <Static> is a consumption counter
1512
+ * here — the event log, the store projection, /export, and /resume keep the
1513
+ * full history; Ctrl+O keeps its inspectable subset. Ink 5's <Static> is a
1514
+ * consumption counter
1479
1515
  * (items.slice(index) keyed on length): deleting head items mid-stream while
1480
1516
  * appending tail items can permanently swallow new rows, so the append branch
1481
1517
  * NEVER drops the head — it only accounts rows and flags `needsTrim` once the
@@ -1509,7 +1545,7 @@ export function computeSettledRows(
1509
1545
  // Full rebuild at the CURRENT fold state, newest-first so the cap keeps
1510
1546
  // whole entries and never even parses dropped ones.
1511
1547
  const records = new Map<TranscriptEntry, SettledRowRecord>()
1512
- const window: ReactElement[] = []
1548
+ const physical: SettledPhysicalRow[] = []
1513
1549
  let windowRows = 0
1514
1550
  let droppedEntries = 0
1515
1551
  let index = settled - 1
@@ -1524,14 +1560,13 @@ export function computeSettledRows(
1524
1560
  }
1525
1561
  records.set(entry, record)
1526
1562
  windowRows += record.rows
1527
- if (record.after !== undefined) window.unshift(record.after)
1528
- window.unshift(record.box)
1529
- if (record.before !== undefined) window.unshift(record.before)
1563
+ physical.unshift(...record.physical)
1530
1564
  }
1531
1565
  const header = createElement(Header, { key: 'header', resumed })
1566
+ const rowElements = physical.map(row => row.element)
1532
1567
  const flat = droppedEntries > 0
1533
- ? [header, settledTrimHint(droppedEntries, columns), ...window]
1534
- : [header, ...window]
1568
+ ? [header, settledTrimHint(droppedEntries, columns), ...rowElements]
1569
+ : [header, ...rowElements]
1535
1570
  return {
1536
1571
  cache: {
1537
1572
  entries: entries.slice(droppedEntries, settled),
@@ -1542,6 +1577,7 @@ export function computeSettledRows(
1542
1577
  epoch,
1543
1578
  columns,
1544
1579
  flat,
1580
+ physical,
1545
1581
  droppedEntries,
1546
1582
  totalRows: windowRows,
1547
1583
  needsTrim: false,
@@ -1565,7 +1601,7 @@ export function computeSettledRows(
1565
1601
  // overflow only flags the cache for one trimming replay.
1566
1602
  const records = previous.records
1567
1603
  const suffix: TranscriptEntry[] = []
1568
- const added: ReactElement[] = []
1604
+ const added: SettledPhysicalRow[] = []
1569
1605
  let deltaRows = 0
1570
1606
  for (let index = previous.entries.length + previous.droppedEntries; index < settled; index++) {
1571
1607
  const entry = entries[index]
@@ -1573,9 +1609,7 @@ export function computeSettledRows(
1573
1609
  records.set(entry, record)
1574
1610
  suffix.push(entry)
1575
1611
  deltaRows += record.rows
1576
- if (record.before !== undefined) added.push(record.before)
1577
- added.push(record.box)
1578
- if (record.after !== undefined) added.push(record.after)
1612
+ added.push(...record.physical)
1579
1613
  }
1580
1614
  const totalRows = previous.totalRows + deltaRows
1581
1615
  const needsTrim = rowCap > 0 && totalRows > rowCap + Math.floor(rowCap / 4)
@@ -1588,7 +1622,8 @@ export function computeSettledRows(
1588
1622
  showReasoning,
1589
1623
  epoch: previous.epoch,
1590
1624
  columns: previous.columns,
1591
- flat: previous.flat.concat(added),
1625
+ flat: previous.flat.concat(added.map(row => row.element)),
1626
+ physical: previous.physical.concat(added),
1592
1627
  droppedEntries: previous.droppedEntries,
1593
1628
  totalRows,
1594
1629
  needsTrim,
@@ -1935,6 +1970,12 @@ export function App(props: AppProps): ReactElement {
1935
1970
  // The Ctrl+O inspector is the one surface the composer already yields to
1936
1971
  // through verboseOpen; it rides the same gate without a panel row.
1937
1972
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
1973
+ // Filtering is inspector-only: the durable log and ordinary transcript keep
1974
+ // every reasoning settlement. Avoid scanning long histories while closed.
1975
+ const verboseEntries = useMemo(
1976
+ () => verboseOpen ? inspectableTranscriptEntries(view.entries) : [],
1977
+ [verboseOpen, view.entries],
1978
+ )
1938
1979
  const modalVisible = openPanel !== undefined || inspectorVisible || approvalPending || questionPending
1939
1980
  // While a deletion waits for y/n, the composer takes the keys (the resume
1940
1981
  // panel yields): the confirm is typed IN the input box, not as an invisible
@@ -1955,21 +1996,17 @@ export function App(props: AppProps): ReactElement {
1955
1996
  setVerboseOpen(false)
1956
1997
  }, [approvalPending, questionPending])
1957
1998
 
1958
- // Append-only transcript: everything up to the first still-mutable entry
1959
- // (a running tool/retry/command) flushes through Ink's `<Static>` into native
1960
- // scrollback and is normally never rewritten the Claude-Code stability
1961
- // contract that lets arbitrarily long conversations scroll instead of
1962
- // freezing when the live tree exceeds the terminal height. The dynamic
1963
- // region below stays small: the streaming tail, modals, composer, and its
1964
- // status footer. Live stream frames preserve `entries` identity.
1999
+ // Append-only transcript with a physical-row viewport: old FINAL rows flush
2000
+ // through Ink's `<Static>` into native scrollback, while one terminal-sized
2001
+ // suffix remains mutable beside running/streaming rows. The suffix contains
2002
+ // real transcript content—not padding—so once content naturally fills the
2003
+ // screen the composer stays at the bottom through live-to-settled changes.
1965
2004
  //
1966
- // `computeSettledRows` extends the cached row set incrementally: the
1967
- // settled prefix is permanently final, so a grown boundary builds ONLY the
1968
- // newly settled suffix and reuses every cached element long histories
1969
- // stop re-creating rows (and re-parsing MarkdownBody) on every durable
1970
- // event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L)
1971
- // rebuilds the CURRENT row set from index 0,
1972
- // so the replay stays complete and never ghosts a pending/running tail.
2005
+ // `computeSettledRows` builds stable one-row records incrementally. The
2006
+ // viewport owns a monotonic row-level flush cursor over those records, so an
2007
+ // oversized entry may split cleanly between Static and the live tail. A
2008
+ // source-backed replay (`refreshEpoch` bump: resize / Ctrl+L) rebuilds and
2009
+ // flushes the complete current row set exactly once.
1973
2010
  // Hook order is unconditional. Its dimensions drive every live-region
1974
2011
  // budget before any dynamic rows are constructed.
1975
2012
  const appStdout = useStdout().stdout
@@ -1979,7 +2016,8 @@ export function App(props: AppProps): ReactElement {
1979
2016
  }))
1980
2017
  const terminalSizeRef = useRef(terminalSize)
1981
2018
  const settledRowsCache = useRef<SettledRowsCache | undefined>(undefined)
1982
- const settledRows = useMemo(() => {
2019
+ const viewportFlushRef = useRef({ sessionKey: props.sessionKey, epoch: refreshEpoch, rows: 0 })
2020
+ const settledRowsResult = useMemo(() => {
1983
2021
  const result = computeSettledRows(
1984
2022
  settledRowsCache.current,
1985
2023
  view.entries,
@@ -1990,7 +2028,7 @@ export function App(props: AppProps): ReactElement {
1990
2028
  terminalSize.columns,
1991
2029
  )
1992
2030
  settledRowsCache.current = result.cache
1993
- return result.cache.flat
2031
+ return result
1994
2032
  }, [view.entries, settled, showReasoning, props.resumed, refreshEpoch, terminalSize.columns])
1995
2033
 
1996
2034
  // One pending synchronized frame covers a debounced resize or explicit
@@ -2084,41 +2122,93 @@ export function App(props: AppProps): ReactElement {
2084
2122
  // session title; cleared on unmount so the host shell regains its default.
2085
2123
  const tabTitle = view.title === '' ? DEFAULT_TERMINAL_TITLE : view.title
2086
2124
  useTerminalTitle(tabTitle)
2087
- const allLiveLines = useMemo(
2125
+ // A single physical-row ledger owns the Static/live split. Keep at most one
2126
+ // maximum transcript viewport mutable; as durable rows append, only overflow
2127
+ // crosses the monotonic flush cursor into native scrollback. Temporary chrome
2128
+ // merely slices the retained rows and can reveal them again when it closes.
2129
+ const maximumTranscriptRows = liveRegionBudget({
2130
+ terminalRows,
2131
+ composerRows: 1,
2132
+ statusBarRows: 1,
2133
+ menuRows: 0,
2134
+ gutterRows: composerGutterRows,
2135
+ notice: false,
2136
+ todo: false,
2137
+ agents: false,
2138
+ })
2139
+ const settledPhysical = settledRowsResult.cache.physical
2140
+ const viewportStep = advanceTranscriptViewport(
2141
+ {
2142
+ sessionKey: viewportFlushRef.current.sessionKey,
2143
+ epoch: viewportFlushRef.current.epoch,
2144
+ flushedRows: viewportFlushRef.current.rows,
2145
+ },
2146
+ {
2147
+ sessionKey: props.sessionKey,
2148
+ epoch: refreshEpoch,
2149
+ totalRows: settledPhysical.length,
2150
+ retainedRows: maximumTranscriptRows,
2151
+ },
2152
+ )
2153
+ const flushedRows = viewportStep.staticRows
2154
+ viewportFlushRef.current = {
2155
+ sessionKey: viewportStep.cursor.sessionKey,
2156
+ epoch: viewportStep.cursor.epoch,
2157
+ rows: viewportStep.cursor.flushedRows,
2158
+ }
2159
+ const staticPrefixRows = settledRowsResult.cache.flat.length - settledPhysical.length
2160
+ const settledRows = settledRowsResult.cache.flat.slice(0, staticPrefixRows + flushedRows)
2161
+ const retainedSettledLines = settledPhysical.slice(flushedRows).map(row => row.line)
2162
+ const mutableLiveLines = useMemo(
2088
2163
  () => view.entries.slice(settled).flatMap(
2089
2164
  // Width shrinks with the real terminal (no 10-column floor: on a
2090
- // narrower terminal the floor silently overflowed every row).
2165
+ // narrower terminal the floor silently overflowed every mutable row).
2091
2166
  entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning),
2092
2167
  ),
2093
2168
  [view.entries, settled, terminalColumns, showReasoning],
2094
2169
  )
2095
- // Reserve the same stream slice from the moment a turn becomes busy. This
2096
- // keeps the first thinking frame from changing the dynamic-tree geometry
2097
- // underneath Ink's cursor ledger and avoids a start-of-thinking flash.
2098
- const liveBudget = busy || streamingActive
2170
+ const allLiveLines = retainedSettledLines.concat(mutableLiveLines)
2171
+
2172
+ // Give streams a bounded provisional maximum, then reclaim every row they
2173
+ // do not ACTUALLY paint for real retained history. Once transcript content
2174
+ // has naturally filled the viewport, this keeps composer geometry constant
2175
+ // through stream growth, settlement, and running-tool replacement—without
2176
+ // synthetic blank rows.
2177
+ const provisionalLiveBudget = busy || streamingActive
2099
2178
  ? Math.max(1, Math.floor(dynamicRows / 3))
2100
2179
  : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
2101
- const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
2180
+ const provisionalLiveRows = Math.min(allLiveLines.length, provisionalLiveBudget)
2181
+ const provisionalStreamRows = Math.max(1, dynamicRows - provisionalLiveRows)
2182
+ const provisionalReasoningRows = view.streamingReasoning === ''
2183
+ ? 0
2184
+ : view.streaming === ''
2185
+ ? provisionalStreamRows
2186
+ : provisionalStreamRows <= 1
2187
+ ? 0
2188
+ : showReasoning
2189
+ ? Math.max(1, Math.floor(provisionalStreamRows / 3))
2190
+ : 1
2191
+ const provisionalAnswerRows = view.streaming === ''
2192
+ ? 0
2193
+ : Math.max(1, provisionalStreamRows - provisionalReasoningRows)
2194
+ const streamColumns = Math.max(1, terminalColumns - 2)
2195
+ const reasoningRows = view.streamingReasoning === '' || provisionalReasoningRows === 0
2196
+ ? 0
2197
+ : showReasoning
2198
+ ? streamTailPhysicalRows(view.streamingReasoning, streamColumns, provisionalReasoningRows, '✻ ', ' ')
2199
+ : 1
2200
+ const answerRows = view.streaming === '' || provisionalAnswerRows === 0
2201
+ ? 0
2202
+ : streamTailPhysicalRows(view.streaming, streamColumns, provisionalAnswerRows, ' ')
2203
+ const nonHistoryRows = reasoningRows + answerRows + (deepDivingVisible ? 1 : 0)
2204
+ const visibleHistoryRows = visibleTranscriptRows(allLiveLines.length, nonHistoryRows, dynamicRows)
2205
+ const visibleLiveLines = visibleHistoryRows === 0 ? [] : allLiveLines.slice(-visibleHistoryRows)
2102
2206
 
2103
2207
  // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
2104
2208
  // screen AND scrollback, home the cursor) then a Static remount via the
2105
2209
  // key change, which re-flushes the current items from index 0. NEVER
2106
2210
  // console.clear() — it desyncs Ink's internal line ledger against the
2107
2211
  // flushed static rows and garbles every frame after.
2108
- const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
2109
- const reasoningRows = view.streamingReasoning === ''
2110
- ? 0
2111
- : view.streaming === ''
2112
- ? streamRows
2113
- : streamRows <= 1
2114
- ? 0
2115
- : showReasoning
2116
- ? Math.max(1, Math.floor(streamRows / 3))
2117
- : 1
2118
- const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2119
- // Dynamic-height tripwire: the allocation must fit dynamicRows by
2120
- // construction; a future edit that breaks the derivation clamps here
2121
- // (answer, then reasoning, then settled live rows) and warns once.
2122
2212
  const liveAudit = clampLiveAllocation(
2123
2213
  { live: visibleLiveLines.length, reasoning: reasoningRows, answer: answerRows },
2124
2214
  dynamicRows,
@@ -2136,6 +2226,15 @@ export function App(props: AppProps): ReactElement {
2136
2226
  : visibleLiveLines.slice(-liveAudit.allocation.live)
2137
2227
  const auditedReasoningRows = liveAudit.allocation.reasoning
2138
2228
  const auditedAnswerRows = liveAudit.allocation.answer
2229
+ const transcriptViewportFilled = allLiveLines.length + nonHistoryRows >= dynamicRows
2230
+ const modalViewportFilled = allLiveLines.length >= dynamicRows
2231
+ const anchoredSurfaceRows = dynamicRows
2232
+ + (transcriptVisible && view.todos.length > 0 ? 1 : 0)
2233
+ + (transcriptVisible && agentRows.length > 0 ? 1 : 0)
2234
+ const surfaceAnchored = transcriptVisible ? transcriptViewportFilled : modalViewportFilled
2235
+ const frozenModalLines = surfaceAnchored && !transcriptVisible
2236
+ ? allLiveLines.slice(-dynamicRows)
2237
+ : []
2139
2238
  // The surface that currently owns the keyboard, named in the frozen band:
2140
2239
  // an empty composer under a panel must not advertise typing it cannot
2141
2240
  // accept — every key actually feeds the panel (which may or may not
@@ -2206,7 +2305,8 @@ export function App(props: AppProps): ReactElement {
2206
2305
  const attachedView = useSyncExternalStore(subscribeAttached, readAttached)
2207
2306
  const attachedSettled = useMemo(() => settledEntryCount(attachedView.entries), [attachedView.entries])
2208
2307
  const attachedRowsCache = useRef<SettledRowsCache | undefined>(undefined)
2209
- const attachedSettledRows = useMemo(() => {
2308
+ const attachedViewportFlushRef = useRef({ sessionKey: 'none', epoch: refreshEpoch, rows: 0 })
2309
+ const attachedSettledRowsResult = useMemo(() => {
2210
2310
  const result = computeSettledRows(
2211
2311
  attachedRowsCache.current,
2212
2312
  attachedView.entries,
@@ -2217,8 +2317,34 @@ export function App(props: AppProps): ReactElement {
2217
2317
  terminalSize.columns,
2218
2318
  )
2219
2319
  attachedRowsCache.current = result.cache
2220
- return result.cache.flat
2320
+ return result
2221
2321
  }, [attachedView.entries, attachedSettled, showReasoning, refreshEpoch, terminalSize.columns])
2322
+ const attachDynamicRows = Math.max(1, terminalRows - 3 - 1 - 1 - 2)
2323
+ const attachedPhysical = attachedSettledRowsResult.cache.physical
2324
+ const attachedViewportStep = advanceTranscriptViewport(
2325
+ {
2326
+ sessionKey: attachedViewportFlushRef.current.sessionKey,
2327
+ epoch: attachedViewportFlushRef.current.epoch,
2328
+ flushedRows: attachedViewportFlushRef.current.rows,
2329
+ },
2330
+ {
2331
+ sessionKey: attachment?.id ?? 'none',
2332
+ epoch: refreshEpoch,
2333
+ totalRows: attachedPhysical.length,
2334
+ retainedRows: attachDynamicRows,
2335
+ },
2336
+ )
2337
+ attachedViewportFlushRef.current = {
2338
+ sessionKey: attachedViewportStep.cursor.sessionKey,
2339
+ epoch: attachedViewportStep.cursor.epoch,
2340
+ rows: attachedViewportStep.cursor.flushedRows,
2341
+ }
2342
+ const attachedStaticPrefixRows = attachedSettledRowsResult.cache.flat.length - attachedPhysical.length
2343
+ const attachedSettledRows = attachedSettledRowsResult.cache.flat.slice(
2344
+ 0,
2345
+ attachedStaticPrefixRows + attachedViewportStep.staticRows,
2346
+ )
2347
+ const attachedRetainedLines = attachedPhysical.slice(attachedViewportStep.staticRows).map(row => row.line)
2222
2348
  /** Detach and hand the keyboard back to the /agents list. */
2223
2349
  const detach = useCallback((): void => {
2224
2350
  setAttachment(current => {
@@ -2611,18 +2737,18 @@ export function App(props: AppProps): ReactElement {
2611
2737
  // ride <Static> above (items switch), and the live region mirrors the
2612
2738
  // main conversation's tail with a simplified chrome budget (readonly
2613
2739
  // bar band + one status row + one gutter + Ink's two spare rows).
2614
- const attachDynamicRows = Math.max(1, terminalRows - 3 - 1 - 1 - 2)
2615
2740
  const attachBusy = attachedView.busySince !== undefined
2616
2741
  const attachStreaming = attachedView.streaming !== '' || attachedView.streamingReasoning !== ''
2617
- const attachAllLiveLines = attachedView.entries.slice(attachedSettled).flatMap(
2742
+ const attachMutableLines = attachedView.entries.slice(attachedSettled).flatMap(
2618
2743
  entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning),
2619
2744
  )
2620
- const attachLiveBudget = attachBusy || attachStreaming
2621
- ? Math.max(1, Math.floor(attachDynamicRows / 3))
2622
- : Math.max(0, attachDynamicRows - 1)
2623
- const attachVisibleLive = attachLiveBudget === 0 ? [] : attachAllLiveLines.slice(-attachLiveBudget)
2624
- const attachStreamRows = Math.max(1, attachDynamicRows - attachVisibleLive.length)
2625
- const attachReasoningRows = attachedView.streamingReasoning === ''
2745
+ const attachAllLiveLines = attachedRetainedLines.concat(attachMutableLines)
2746
+ const attachProvisionalLiveRows = Math.min(
2747
+ attachAllLiveLines.length,
2748
+ attachBusy || attachStreaming ? Math.max(1, Math.floor(attachDynamicRows / 3)) : attachDynamicRows,
2749
+ )
2750
+ const attachStreamRows = Math.max(1, attachDynamicRows - attachProvisionalLiveRows)
2751
+ const attachReasoningLimit = attachedView.streamingReasoning === ''
2626
2752
  ? 0
2627
2753
  : attachedView.streaming === ''
2628
2754
  ? attachStreamRows
@@ -2631,11 +2757,30 @@ export function App(props: AppProps): ReactElement {
2631
2757
  : showReasoning
2632
2758
  ? Math.max(1, Math.floor(attachStreamRows / 3))
2633
2759
  : 1
2634
- const attachAnswerRows = attachedView.streaming === '' ? 0 : Math.max(1, attachStreamRows - attachReasoningRows)
2760
+ const attachAnswerLimit = attachedView.streaming === '' ? 0 : Math.max(1, attachStreamRows - attachReasoningLimit)
2761
+ const attachReasoningRows = attachedView.streamingReasoning === '' || attachReasoningLimit === 0
2762
+ ? 0
2763
+ : showReasoning
2764
+ ? streamTailPhysicalRows(attachedView.streamingReasoning, Math.max(1, terminalColumns - 2), attachReasoningLimit, '✻ ', ' ')
2765
+ : 1
2766
+ const attachAnswerRows = attachedView.streaming === '' || attachAnswerLimit === 0
2767
+ ? 0
2768
+ : streamTailPhysicalRows(attachedView.streaming, Math.max(1, terminalColumns - 2), attachAnswerLimit, ' ')
2769
+ const attachNonHistoryRows = attachReasoningRows + attachAnswerRows
2770
+ + (attachBusy && !attachStreaming ? 1 : 0)
2771
+ const attachVisibleHistoryRows = visibleTranscriptRows(
2772
+ attachAllLiveLines.length,
2773
+ attachNonHistoryRows,
2774
+ attachDynamicRows,
2775
+ )
2776
+ const attachVisibleLive = attachVisibleHistoryRows === 0
2777
+ ? []
2778
+ : attachAllLiveLines.slice(-attachVisibleHistoryRows)
2635
2779
  const attachAudit = clampLiveAllocation(
2636
2780
  { live: attachVisibleLive.length, reasoning: attachReasoningRows, answer: attachAnswerRows },
2637
2781
  attachDynamicRows,
2638
2782
  )
2783
+ const attachViewportFilled = attachAllLiveLines.length + attachNonHistoryRows >= attachDynamicRows
2639
2784
  const attachTurns = attachedView.entries.filter(entry => entry.kind === 'turn-marker').length
2640
2785
  return createElement(
2641
2786
  Box,
@@ -2646,7 +2791,9 @@ export function App(props: AppProps): ReactElement {
2646
2791
  }),
2647
2792
  createElement(
2648
2793
  Box,
2649
- { flexDirection: 'column' },
2794
+ attachViewportFilled
2795
+ ? { flexDirection: 'column', height: attachDynamicRows }
2796
+ : { flexDirection: 'column' },
2650
2797
  attachAudit.allocation.live === attachVisibleLive.length && attachVisibleLive.length > 0
2651
2798
  ? createElement(StyledRows, { lines: attachVisibleLive })
2652
2799
  : undefined,
@@ -2667,10 +2814,10 @@ export function App(props: AppProps): ReactElement {
2667
2814
  maxRows: attachAudit.allocation.answer,
2668
2815
  prefix: ' ',
2669
2816
  columns: Math.max(1, terminalColumns - 2),
2670
- }, attachBusy ? createElement(Caret, { animated: animations }) : undefined)
2817
+ }, attachBusy ? createElement(Caret) : undefined)
2671
2818
  : undefined,
2672
2819
  attachBusy && attachedView.streaming === '' && attachedView.streamingReasoning === ''
2673
- ? createElement(DeepDivingLine, { since: attachedView.busySince, animated: animations })
2820
+ ? createElement(DeepDivingLine, { since: attachedView.busySince, themeFlowAnimated: animations })
2674
2821
  : undefined,
2675
2822
  ),
2676
2823
  createElement(
@@ -2696,7 +2843,19 @@ export function App(props: AppProps): ReactElement {
2696
2843
  key: refreshEpoch,
2697
2844
  items: attachment === undefined ? settledRows : attachedSettledRows,
2698
2845
  }),
2699
- transcriptVisible
2846
+ createElement(
2847
+ Box,
2848
+ surfaceAnchored
2849
+ ? { flexDirection: 'column', height: anchoredSurfaceRows }
2850
+ : { flexDirection: 'column' },
2851
+ frozenModalLines.length === 0
2852
+ ? undefined
2853
+ : createElement(
2854
+ Box,
2855
+ { flexDirection: 'column', height: 0, flexGrow: 1, flexShrink: 1, overflow: 'hidden', justifyContent: 'flex-end' },
2856
+ createElement(StyledRows, { lines: frozenModalLines }),
2857
+ ),
2858
+ transcriptVisible
2700
2859
  ? createElement(
2701
2860
  Box,
2702
2861
  // No container padding: every live row carries its own two-column
@@ -2722,7 +2881,7 @@ export function App(props: AppProps): ReactElement {
2722
2881
  // marker falls back to the static dim row — same as Deep diving
2723
2882
  // always yields the live region to streaming content.
2724
2883
  : view.streaming === ''
2725
- ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)', animated: animations })
2884
+ ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)', themeFlowAnimated: animations })
2726
2885
  : createElement(StreamTail, {
2727
2886
  text: 'Thinking… (Ctrl/Alt+R to expand)',
2728
2887
  prefix: '✻ ',
@@ -2738,10 +2897,10 @@ export function App(props: AppProps): ReactElement {
2738
2897
  // The same two-column gutter as settled replies: streamed text
2739
2898
  // lands exactly where the assembled message will render.
2740
2899
  { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ', columns: Math.max(1, terminalColumns - 2) },
2741
- busy ? createElement(Caret, { animated: animations }) : undefined,
2900
+ busy ? createElement(Caret) : undefined,
2742
2901
  )
2743
2902
  : undefined,
2744
- deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince, animated: animations }) : undefined,
2903
+ deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince, themeFlowAnimated: animations }) : undefined,
2745
2904
  )
2746
2905
  : undefined,
2747
2906
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
@@ -2802,7 +2961,7 @@ export function App(props: AppProps): ReactElement {
2802
2961
  : undefined,
2803
2962
  verboseOpen && !approvalPending && !questionPending
2804
2963
  ? createElement(MemoVerbosePanel, {
2805
- entries: view.entries,
2964
+ entries: verboseEntries,
2806
2965
  onClose: closeInspector,
2807
2966
  columns: terminalColumns,
2808
2967
  rows: terminalRows,
@@ -2987,6 +3146,7 @@ export function App(props: AppProps): ReactElement {
2987
3146
  close: () => setSubagentOpen(false),
2988
3147
  })
2989
3148
  : undefined,
3149
+ ),
2990
3150
  notice === undefined
2991
3151
  ? undefined
2992
3152
  : createElement(NoticeLine, {
@@ -3190,7 +3350,10 @@ export function App(props: AppProps): ReactElement {
3190
3350
  stats: view.stats,
3191
3351
  busy,
3192
3352
  animated: animations,
3193
- columns: terminalColumns,
3353
+ // Leave one physical terminal column unused. VS Code autowraps a row
3354
+ // painted at EXACTLY stdout.columns; the first-token status update can
3355
+ // otherwise create an unreported row and lift composer + status.
3356
+ columns: Math.max(1, terminalColumns - 1),
3194
3357
  items: statuslineItems,
3195
3358
  onRows: handleStatusRows,
3196
3359
  }),