dsh-oc-tui 0.2.0-pre.13 → 0.2.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.
@@ -91,18 +91,36 @@ export function paintSplashOpenCode(screen, cols, rows, theme, ctx = {}, floor =
91
91
  }
92
92
 
93
93
  // Keyboard shortcuts at bottom: only affordances that actually exist on the
94
- // title screen. A narrow terminal drops the thinking hint whole rather than
95
- // cutting any group in half; settings is the more valuable of the two.
94
+ // title screen. A narrow terminal drops whole groups rather than cutting any
95
+ // group in half; settings is the more valuable of the two, and the branch
96
+ // switcher the least, so the drop order is thinking then branches. Each
97
+ // group paints its key in the accent color and bold so the key reads apart
98
+ // from the muted description that follows it.
96
99
  if (shortcutsY > hintY + 2) {
97
100
  const sep = ' '
98
- let line = ''
99
- for (const group of ['Ctrl+P ' + tr('splash.shortcutSettings'), 'Tab ' + tr('splash.shortcutThinking')]) {
100
- const next = line === '' ? group : line + sep + group
101
- if (displayWidth(next) > bandW - 2) break
102
- line = next
101
+ const pairs = [
102
+ ['Ctrl+P', tr('splash.shortcutSettings')],
103
+ ['Tab', tr('splash.shortcutThinking')],
104
+ ['Ctrl+G', tr('splash.shortcutBranches')],
105
+ ]
106
+ const kept = []
107
+ let width = 0
108
+ for (const [key, desc] of pairs) {
109
+ const groupW = displayWidth(key) + 2 + displayWidth(desc)
110
+ const next = kept.length === 0 ? groupW : width + displayWidth(sep) + groupW
111
+ if (next > bandW - 2) break
112
+ kept.push([key, desc])
113
+ width = next
103
114
  }
104
- if (line) {
105
- screen.text(centerX(line), shortcutsY, line, { fg: theme.textMuted, bg: theme.background })
115
+ if (kept.length > 0) {
116
+ const line = kept.map(([key, desc]) => key + ' ' + desc).join(sep)
117
+ let sx = centerX(line)
118
+ for (let i = 0; i < kept.length; i++) {
119
+ if (i > 0) sx = screen.text(sx, shortcutsY, sep, { fg: theme.textMuted, bg: theme.background })
120
+ const [key, desc] = kept[i]
121
+ sx = screen.text(sx, shortcutsY, key, { fg: theme.accent, bold: true, bg: theme.background })
122
+ sx = screen.text(sx, shortcutsY, ' ' + desc, { fg: theme.textMuted, bg: theme.background })
123
+ }
106
124
  }
107
125
  }
108
126
  }
@@ -15,7 +15,7 @@ export const COMMAND_PLACEMENT = Object.freeze({ apiVersion: 'tui.dsh/v1alpha1',
15
15
  // runCommand asks ctx.commands.find() first, so the harness owns them whenever
16
16
  // it registers them and the TUI only supplies the fallback.
17
17
  export const TUI_OWNED_COMMANDS = Object.freeze([
18
- 'settings', 'help', 'stats', 'new', 'resume', 'clear', 'cancel', 'rewind', 'quit',
18
+ 'settings', 'help', 'stats', 'new', 'resume', 'clear', 'cancel', 'rewind', 'branch', 'quit',
19
19
  ])
20
20
 
21
21
  // Mirrors the `title` of each contributed command in dsh-plugin.json.
@@ -28,5 +28,6 @@ export const COMMAND_DESCRIPTIONS = Object.freeze({
28
28
  clear: 'Clear the transcript',
29
29
  cancel: 'Cancel the running turn',
30
30
  rewind: 'Rewind the session to an earlier point',
31
+ branch: 'Switch git branches on the empty page',
31
32
  quit: 'Leave the TUI',
32
33
  })
package/lib/term.js CHANGED
@@ -431,6 +431,14 @@ export class Terminal extends EventEmitter {
431
431
  super()
432
432
  this.input = input
433
433
  this.output = output
434
+ // The write channel is bound, not looked up per frame. While the TUI owns
435
+ // the alternate screen the process streams are diverted into the
436
+ // diagnostics log; a late `this.output.write(...)` lookup on `process.stdout`
437
+ // would resolve to that diversion and file every frame instead of painting
438
+ // it. `write()` re-binds whenever the output object itself is swapped, so a
439
+ // host (or a test) handing in a new stream still gets painted to.
440
+ this._boundOutput = output
441
+ this._outputWrite = output.write.bind(output)
434
442
  this.raw = false
435
443
  this.started = false
436
444
  this._buffer = Buffer.alloc(0)
@@ -505,7 +513,11 @@ export class Terminal extends EventEmitter {
505
513
  }
506
514
 
507
515
  write(s) {
508
- this.output.write(s)
516
+ if (this._boundOutput !== this.output) {
517
+ this._boundOutput = this.output
518
+ this._outputWrite = this.output.write.bind(this.output)
519
+ }
520
+ this._outputWrite(s)
509
521
  }
510
522
 
511
523
  // Ask the terminal for its clipboard (OSC 52 read). The reply arrives on
package/lib/ui.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { Screen, makeStyle, mergeStyle, hexToAnsi } from './term.js'
4
4
  import { renderMarkdown } from './markdown.js'
5
5
  import { sha16 } from './image.js'
6
- import { displayWidth, truncateWidth, timeString, toolSummary, roughTokens, formatTokens, wrapText, shortenPath, decodeDataUrl } from './util.js'
6
+ import { displayWidth, truncateWidth, timeString, toolSummary, roughTokens, formatTokens, wrapText, shortenPath, decodeDataUrl, reasoningText } from './util.js'
7
7
  import { paintSplashOpenCode } from './splash-opencode.js'
8
8
  import { loadMermaidEngine, paintMermaidArt, renderMermaidArt } from './mermaid-ascii.js'
9
9
  // `tr` on purpose: the paint methods below bind `t` to THEME, and a bare `t`
@@ -95,7 +95,7 @@ const COMMAND_HINTS = [
95
95
  ['/model', 'hint.model'], ['/provider', 'hint.provider'],
96
96
  ['/rewind', 'hint.rewind'], ['/stats', 'hint.stats'],
97
97
  ['/clear', 'hint.clear'], ['/cancel', 'hint.cancel'],
98
- ['/quit', 'hint.quit'],
98
+ ['/quit', 'hint.quit'], ['/branch', 'hint.branch'],
99
99
  ['/compact', 'hint.compact'], ['/goal', 'hint.goal'],
100
100
  ]
101
101
 
@@ -260,6 +260,28 @@ function flatRows(tag, block, markerSegs, body, hang = 4, width = Infinity) {
260
260
  return rows
261
261
  }
262
262
 
263
+ // One-line gist of a collapsed thinking box. A folded row that only says
264
+ // "Thinking" tells the reader nothing about what was thought; the first real
265
+ // line, stripped of its markdown noise, is enough to decide whether to expand.
266
+ // Only the head of the text is scanned — a long chain of thought costs nothing
267
+ // to preview, and the row is clipped to the pane by the caller anyway.
268
+ function thinkingPreview(text) {
269
+ const head = String(text).slice(0, 400)
270
+ for (const raw of head.split(/\r?\n/)) {
271
+ const line = raw.trim()
272
+ if (line === '') continue
273
+ // A fence opener on its own line carries no gist; skip to the next line.
274
+ if (/^```/.test(line)) continue
275
+ return line
276
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
277
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
278
+ .replace(/[*_`#>~|]+/g, '')
279
+ .replace(/\s+/g, ' ')
280
+ .trim()
281
+ }
282
+ return ''
283
+ }
284
+
263
285
  // Per-tool accent for the activity rows: shell commands read as actions
264
286
  // (gold), reads as lookups (cyan), edits as changes (light blue), todo writes
265
287
  // as progress (mint); anything unmapped keeps the light-blue accent.
@@ -384,13 +406,14 @@ export class App {
384
406
  this.railScroll = 0 // lines scrolled up from the rail's bottom (0 = follow)
385
407
  this.versions = { tui: '', dsh: '' }
386
408
  this.rewind = null // rewind picker state, or null
409
+ this.gitBranches = null // git branch switcher state, or null
387
410
  this.inputText = ''
388
411
  this.inputCursor = 0
389
412
  this.inputImages = [] // pasted images: [{ status, ref, mediaType, label }]
390
413
  this.history = []
391
414
  this.historyIndex = -1
392
415
  this.scroll = 0 // lines scrolled up from bottom (0 = follow)
393
- this.overlay = null // 'help' | 'settings' | 'rewind' | null
416
+ this.overlay = null // 'help' | 'settings' | 'rewind' | 'git-branches' | null
394
417
  this.settingsSelection = 0
395
418
  this.settingsEditing = null
396
419
  this.settingsDraft = ''
@@ -437,6 +460,11 @@ export class App {
437
460
  this._imageResults = new Map() // key -> { state: 'loading' | 'done' | 'error', protocol?, cellsW?, cellsH?, segLines?, error? }
438
461
  this._imageBlocks = new Map() // key -> Set of blocks showing it (rev bump on arrival)
439
462
  this._imageRequested = new Set()
463
+ // The geometry each in-flight render was requested for. A resize can land
464
+ // while the host is decoding; stamping the result with what was asked for
465
+ // (not with what is current when it arrives) is what lets the next frame
466
+ // notice the mismatch and render again.
467
+ this._imagePendingSig = new Map()
440
468
  this._imageSources = new Map() // key -> the source that produced it (cropped variants are re-renders of it)
441
469
  this._imageCrops = new Map() // key -> Set of '<key>\u0000<crop>' variants still holding a payload
442
470
  this._imageCropsPrev = new Set() // variants the previous render used (a crop outlives one idle frame)
@@ -492,11 +520,12 @@ export class App {
492
520
  // The settings left menu switches on Tab/click only; hovering it never
493
521
  // steals focus from the item list.
494
522
  if (region?.kind === 'settings-menu') return false
495
- const target = region ? region.kind + ':' + (region.settingsIndex ?? region.sessionIndex ?? region.rewindIndex ?? '') : ''
496
- const focusIndex = region ? (region.settingsIndex ?? region.sessionIndex ?? region.rewindIndex ?? -1) : -1
523
+ const target = region ? region.kind + ':' + (region.settingsIndex ?? region.sessionIndex ?? region.rewindIndex ?? region.branchIndex ?? '') : ''
524
+ const focusIndex = region ? (region.settingsIndex ?? region.sessionIndex ?? region.rewindIndex ?? region.branchIndex ?? -1) : -1
497
525
  const currentFocus = region?.kind === 'settings-item' ? this.settingsSelection
498
526
  : region?.kind === 'rewind-item' ? this.rewind?.selected
499
527
  : region?.kind === 'rewind-option' ? this.rewind?.modeIndex
528
+ : region?.kind === 'git-branch-item' ? this.gitBranches?.selected
500
529
  : this.sidebarSelection
501
530
  const focusDiffers = focusIndex >= 0 && focusIndex !== currentFocus
502
531
  if (target === this._lastHover && !focusDiffers) return false
@@ -505,6 +534,7 @@ export class App {
505
534
  this.focusedRegion = 'mouse'
506
535
  if (region.kind === 'settings-item') this.settingsSelection = region.settingsIndex
507
536
  if (region.kind === 'session') this.sidebarSelection = region.sessionIndex
537
+ if (region.kind === 'git-branch-item' && this.gitBranches) this.gitBranches.selected = region.branchIndex
508
538
  if (this.rewind) {
509
539
  if (region.kind === 'rewind-item') this.rewind.selected = region.rewindIndex
510
540
  if (region.kind === 'rewind-option') this.rewind.modeIndex = region.rewindIndex
@@ -865,6 +895,110 @@ export class App {
865
895
  picker.scroll = Math.max(0, Math.min(picker.scroll, Math.max(0, n - visible)))
866
896
  }
867
897
 
898
+ // ---- git branch switcher ------------------------------------------------
899
+ // Rows for the branch window: one non-selectable header per section, then
900
+ // one row per branch. Rebuilt from the fresh lists on every refresh so the
901
+ // current marker and the remote set can never go stale in an open window.
902
+
903
+ _gitBranchItems(local, remote, current) {
904
+ const items = []
905
+ if (local.length > 0) {
906
+ items.push({ kind: 'header', label: tr('git.local') })
907
+ for (const name of local) {
908
+ items.push({ kind: 'branch', ref: name, name, remote: false, current: name === current })
909
+ }
910
+ }
911
+ if (remote.length > 0) {
912
+ items.push({ kind: 'header', label: tr('git.remote') })
913
+ for (const name of remote) {
914
+ items.push({ kind: 'branch', ref: name, name, remote: true, current: false })
915
+ }
916
+ }
917
+ return items
918
+ }
919
+
920
+ openGitBranches({ cwd = '', root = '', current = '', local = [], remote = [] } = {}) {
921
+ this.gitBranches = {
922
+ cwd,
923
+ root,
924
+ current,
925
+ local,
926
+ remote,
927
+ items: this._gitBranchItems(local, remote, current),
928
+ selected: 0,
929
+ scroll: 0,
930
+ busy: false,
931
+ error: '',
932
+ status: '',
933
+ }
934
+ this.overlay = 'git-branches'
935
+ this._preselectGitBranch(current)
936
+ }
937
+
938
+ closeGitBranches() {
939
+ if (this.overlay === 'git-branches') this.overlay = null
940
+ this.gitBranches = null
941
+ }
942
+
943
+ // Replace the branch data after a switch, pull or refresh. The selection
944
+ // follows the branch it was on when that branch still exists, so a pull
945
+ // that only updates the remote set does not yank the cursor elsewhere; a
946
+ // list that dropped it (a pruned remote branch) lands on the new current
947
+ // branch instead of on an arbitrary row.
948
+ setGitBranchList({ current = '', local = [], remote = [] } = {}) {
949
+ const picker = this.gitBranches
950
+ if (!picker) return
951
+ const row = picker.items[picker.selected]
952
+ const previousRef = row?.kind === 'branch' ? row.ref : null
953
+ picker.current = current
954
+ picker.local = local
955
+ picker.remote = remote
956
+ picker.items = this._gitBranchItems(local, remote, current)
957
+ this._preselectGitBranch(previousRef, current)
958
+ }
959
+
960
+ // Point the selection at the first candidate ref that still has a row, else
961
+ // at the first branch there is. Candidates arrive most-preferred first.
962
+ _preselectGitBranch(...candidates) {
963
+ const picker = this.gitBranches
964
+ if (!picker) return
965
+ let target = -1
966
+ for (const ref of candidates) {
967
+ if (!ref) continue
968
+ target = picker.items.findIndex((item) => item.kind === 'branch' && item.ref === ref)
969
+ if (target >= 0) break
970
+ }
971
+ if (target < 0) target = picker.items.findIndex((item) => item.kind === 'branch')
972
+ picker.selected = target < 0 ? 0 : target
973
+ this._clampGitBranchesScroll()
974
+ }
975
+
976
+ moveGitBranches(delta) {
977
+ const picker = this.gitBranches
978
+ if (!picker || picker.busy) return
979
+ const count = picker.items.length
980
+ if (count === 0) return
981
+ let index = picker.selected
982
+ let steps = count
983
+ while (steps-- > 0) {
984
+ index = (index + delta + count) % count
985
+ if (settingsRowSelectable(picker.items[index])) break
986
+ }
987
+ picker.selected = index
988
+ this._clampGitBranchesScroll()
989
+ }
990
+
991
+ _clampGitBranchesScroll() {
992
+ const picker = this.gitBranches
993
+ if (!picker) return
994
+ const visible = 7
995
+ const n = picker.items.length
996
+ const sel = picker.selected
997
+ if (sel < picker.scroll) picker.scroll = sel
998
+ if (sel >= picker.scroll + visible) picker.scroll = sel - visible + 1
999
+ picker.scroll = Math.max(0, Math.min(picker.scroll, Math.max(0, n - visible)))
1000
+ }
1001
+
868
1002
  // Point the selection at an item index, skipping rows that carry no action
869
1003
  // (kind 'header' rows are never selectable): a landing on one moves down to
870
1004
  // the next real item, or back to the first selectable one.
@@ -947,13 +1081,25 @@ export class App {
947
1081
  }
948
1082
  }
949
1083
 
1084
+ // Identity of the geometry every image render is built for. A result carries
1085
+ // the signature it was produced under, so a frame at a different width (a
1086
+ // resize, a sidebar toggled open) can tell that the raster on hand was scaled
1087
+ // for the old grid and ask again, instead of drawing it at the wrong size
1088
+ // until the session is reopened.
1089
+ imageRenderSignature() {
1090
+ const o = this.imageRenderOptions()
1091
+ return [o.protocol, o.maxCellsW, o.maxCellsH, o.cellW, o.cellH].join('|')
1092
+ }
1093
+
950
1094
  // A render landed for an image key: cache it and refresh every block that
951
1095
  // shows the source so the next frame picks the finished render up. The block
952
1096
  // set survives the bump — a result can be replaced (protocol upgrade after
953
1097
  // caps probing, width re-render) and the replacement must reach the same
954
1098
  // blocks; the map is cleared with the line cache in resetView.
955
1099
  setImageResult(key, result) {
956
- this._imageResults.set(key, result)
1100
+ const sig = this._imagePendingSig.get(key) ?? this.imageRenderSignature()
1101
+ this._imagePendingSig.delete(key)
1102
+ this._imageResults.set(key, { ...result, sig })
957
1103
  const blocks = this._imageBlocks.get(key)
958
1104
  if (blocks) {
959
1105
  for (const b of blocks) b.rev = (b.rev ?? 0) + 1
@@ -976,6 +1122,7 @@ export class App {
976
1122
  const src = this._imageSources.get(key)
977
1123
  if (!src) return
978
1124
  live.add(cropKey)
1125
+ this._imagePendingSig.set(cropKey, this.imageRenderSignature())
979
1126
  this.onImageRequest?.({ ...src, key: cropKey, cropCells: clip })
980
1127
  }
981
1128
 
@@ -996,7 +1143,19 @@ export class App {
996
1143
  out.push({ segs: [{ text: tr('image.attachment'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
997
1144
  return
998
1145
  }
1146
+ // Geometry check: a finished render whose signature no longer matches the
1147
+ // current grid was scaled for a different width and would draw at the wrong
1148
+ // size inside this one. Drop it — and the bookkeeping around it, or the
1149
+ // request would never be repeated — so the placeholder shows for the frame
1150
+ // or two the re-render takes.
1151
+ const sig = this.imageRenderSignature()
999
1152
  let result = this._imageResults.get(key)
1153
+ if (result && result.sig !== undefined && result.sig !== sig) {
1154
+ this._imageResults.delete(key)
1155
+ this._imageRequested.delete(key)
1156
+ this._imageCrops.delete(key)
1157
+ result = undefined
1158
+ }
1000
1159
  if (!result) {
1001
1160
  result = { state: 'loading' }
1002
1161
  this._imageResults.set(key, result)
@@ -1013,6 +1172,7 @@ export class App {
1013
1172
  if (result.state === 'loading') {
1014
1173
  if (!this._imageRequested.has(key)) {
1015
1174
  this._imageRequested.add(key)
1175
+ this._imagePendingSig.set(key, sig)
1016
1176
  this.onImageRequest?.(src)
1017
1177
  }
1018
1178
  out.push({ segs: [{ text: tr('image.rendering'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
@@ -1070,6 +1230,17 @@ export class App {
1070
1230
  }
1071
1231
 
1072
1232
  const engine = this._mermaidEngine
1233
+ // A fence that is still streaming is a partial diagram: laying it out costs
1234
+ // a full parse per frame for a picture that changes on the next chunk, and
1235
+ // the half-drawn boxes flash as it grows. Keep the source visible until the
1236
+ // block settles — the finished block invalidates the line cache and draws
1237
+ // the diagram then.
1238
+ if (!off && block?.streaming === true) {
1239
+ for (const line of renderMarkdown('```mermaid\n' + code + '\n```', THEME, width)) {
1240
+ out.push({ segs: line })
1241
+ }
1242
+ return
1243
+ }
1073
1244
  if (!off) {
1074
1245
  if (engine === 'loading') {
1075
1246
  out.push({ segs: [{ text: tr('image.mermaidRendering'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
@@ -1204,7 +1375,9 @@ export class App {
1204
1375
  b.rev++
1205
1376
  // Thinking is collapsed by default once it has finished streaming.
1206
1377
  if (b.reasoning && b.thinkingCollapsed === undefined) b.thinkingCollapsed = true
1207
- if (b.text === '' && b.reasoning === '') this.blocks.splice(i, 1)
1378
+ // A signature-only block is as empty as one that never reasoned: left
1379
+ // in place it would draw a bare `dsh` header over nothing.
1380
+ if (b.text === '' && !reasoningText(b.reasoning)) this.blocks.splice(i, 1)
1208
1381
  break
1209
1382
  }
1210
1383
  }
@@ -1405,6 +1578,7 @@ export class App {
1405
1578
  this._imageResults.clear()
1406
1579
  this._imageBlocks.clear()
1407
1580
  this._imageRequested.clear()
1581
+ this._imagePendingSig.clear()
1408
1582
  this._imageSources.clear()
1409
1583
  this._imageCrops.clear()
1410
1584
  this._imageCropsPrev = new Set()
@@ -1417,7 +1591,7 @@ export class App {
1417
1591
  // Toggle a completed thinking box between collapsed and expanded. Thinking
1418
1592
  // stays collapsed while it is still streaming.
1419
1593
  toggleThinking(block) {
1420
- if (!block || block.kind !== 'assistant' || !block.reasoning || block.streaming) return
1594
+ if (!block || block.kind !== 'assistant' || !reasoningText(block.reasoning) || block.streaming) return
1421
1595
  block.thinkingCollapsed = block.thinkingCollapsed !== true
1422
1596
  block.rev++
1423
1597
  }
@@ -1603,12 +1777,17 @@ export class App {
1603
1777
  break
1604
1778
  }
1605
1779
  case 'assistant': {
1780
+ // The displayable reasoning, with the transport signature stripped. A
1781
+ // block whose reasoning is only a signature has no thinking to show, so
1782
+ // every decision below — including whether the block is empty at all —
1783
+ // is taken on this text rather than on the raw accumulation.
1784
+ const reasoning = reasoningText(block.reasoning)
1606
1785
  // A message that only issued tool calls has nothing to say on its own:
1607
1786
  // its activity lives in the tool rows below, and drawing an empty frame
1608
1787
  // anyway is what produced the empty bubbles in long tool turns. The
1609
1788
  // turn's `dsh · time` header must not be swallowed here — pass it to
1610
1789
  // the next assistant block if there is one.
1611
- if (block.text === '' && !block.reasoning) {
1790
+ if (block.text === '' && !reasoning) {
1612
1791
  if (block.showHeader === true) {
1613
1792
  // The next assistant message can sit behind this one's tool rows —
1614
1793
  // scan past them.
@@ -1627,7 +1806,7 @@ export class App {
1627
1806
  // body hangs under it in muted italics. No frame and no full-width
1628
1807
  // band — the boxed shapes measured their own borders and drifted on
1629
1808
  // wide runes, and the band read as a bar across the transcript.
1630
- if (block.reasoning) {
1809
+ if (reasoning) {
1631
1810
  const streaming = block.streaming === true
1632
1811
  const collapsed = block.thinkingCollapsed === true || streaming
1633
1812
  const hint = streaming ? tr('transcript.streaming') : (collapsed ? tr('common.clickExpand') : tr('common.clickCollapse'))
@@ -1638,14 +1817,25 @@ export class App {
1638
1817
  : { text: collapsed ? '▸' : '▾', style: makeStyle({ fg: t.thinking, bold: true }) }
1639
1818
  const body = []
1640
1819
  if (!collapsed) {
1641
- for (const line of renderMarkdown(block.reasoning, t, Math.max(8, width - 6))) {
1820
+ for (const line of renderMarkdown(reasoning, t, Math.max(8, width - 6))) {
1642
1821
  body.push(line.map((s) => ({ ...s, style: mergeStyle(s.style, { fg: t.thinking, italic: true }) })))
1643
1822
  }
1644
1823
  }
1824
+ // Folded and finished: name the row, then show what it was about.
1825
+ // While streaming the text is still arriving, so a preview would
1826
+ // flicker; the running hint stands in for it.
1827
+ const preview = collapsed && !streaming ? thinkingPreview(reasoning) : ''
1645
1828
  out.push(...flatRows('thinking', block, [
1646
1829
  { text: ' ', style: null },
1647
1830
  marker,
1648
- { text: tr('transcript.thinking') + hint, style: makeStyle({ fg: t.thinking, bold: true }) },
1831
+ { text: tr('transcript.thinking'), style: makeStyle({ fg: t.thinking, bold: true }) },
1832
+ ...(preview
1833
+ ? [
1834
+ { text: ' · ', style: makeStyle({ fg: t.thinking, dim: true }) },
1835
+ { text: truncateWidth(preview, 48), style: makeStyle({ fg: t.thinking, italic: true }) },
1836
+ ]
1837
+ : []),
1838
+ { text: hint, style: makeStyle({ fg: t.thinking, bold: true }) },
1649
1839
  ], body, 4, width))
1650
1840
  // A blank line separates the reasoning from the visible answer so
1651
1841
  // the two never run together.
@@ -2343,6 +2533,7 @@ export class App {
2343
2533
  if (this.overlay === 'help') this._paintHelp(screen, cols, rows, railW)
2344
2534
  if (this.overlay === 'settings') this._paintSettings(screen, cols, rows, railW)
2345
2535
  if (this.overlay === 'rewind') this._paintRewind(screen, cols, rows, railW)
2536
+ if (this.overlay === 'git-branches') this._paintGitBranches(screen, cols, rows, railW)
2346
2537
  // The question modal owns the screen while a request is pending.
2347
2538
  this._questionCaret = null
2348
2539
  if (this.pendingQuestions) this._paintQuestions(screen, cols, rows, railW)
@@ -3305,6 +3496,10 @@ export class App {
3305
3496
  screen.text(cx + 6 + indent, y, truncateWidth(item.label, Math.max(6, 22 - indent)), style)
3306
3497
  screen.text(cx + 30, y, '\u2588'.repeat(done) + '\u2591'.repeat(barW - done), barStyle)
3307
3498
  screen.text(cx + 30 + barW + 1, y, truncateWidth(value, Math.max(4, cw - 41 - 5)), valueStyle)
3499
+ } else if (item.wide === true) {
3500
+ // A sentence, not a label/value pair: rows that explain rather than
3501
+ // configure get the whole content width instead of the value column.
3502
+ screen.text(cx + 6 + indent, y, truncateWidth(item.label, cw - 8 - indent), style)
3308
3503
  } else {
3309
3504
  screen.text(cx + 6 + indent, y, truncateWidth(item.label, Math.max(6, 29 - indent)), style)
3310
3505
  screen.text(cx + 36, y, truncateWidth(value, cw - 41), valueStyle)
@@ -3317,7 +3512,14 @@ export class App {
3317
3512
  : hasMenu
3318
3513
  ? tr('settings.footerMenu')
3319
3514
  : tr('settings.footerPlain')
3320
- screen.text(cx + 3, y0 + h - 2, footer, makeStyle({ fg: this.settingsConfirm ? t.warning : t.textMuted, bg: t.backgroundElement }))
3515
+ // A pending confirm repaints the whole row as a warning; otherwise the
3516
+ // key token leads each pair in the accent color so it reads apart from
3517
+ // its description.
3518
+ if (this.settingsConfirm) {
3519
+ screen.text(cx + 3, y0 + h - 2, footer, makeStyle({ fg: t.warning, bg: t.backgroundElement }))
3520
+ } else {
3521
+ paintKeyHints(screen, cx + 3, y0 + h - 2, footer, cw - 6, t)
3522
+ }
3321
3523
  }
3322
3524
 
3323
3525
  _paintHelp(screen, cols, rows, railW = 0) {
@@ -3349,6 +3551,7 @@ export class App {
3349
3551
  [tr('help.ctrlE'), tr('help.ctrlEDesc')],
3350
3552
  [tr('help.tab'), tr('help.tabDesc')],
3351
3553
  [tr('help.ctrlN'), tr('help.ctrlNDesc')],
3554
+ [tr('help.ctrlG'), tr('help.ctrlGDesc')],
3352
3555
  [tr('help.ctrlD'), tr('help.ctrlDDesc')],
3353
3556
  [tr('help.pg'), tr('help.pgDesc')],
3354
3557
  [tr('help.shiftArrows'), tr('help.shiftArrowsDesc')],
@@ -3372,7 +3575,7 @@ export class App {
3372
3575
  yy++
3373
3576
  screen.text(x0 + 3, yy, tr('help.commands'), makeStyle({ fg: t.accent, bold: true, bg: t.backgroundElement }))
3374
3577
  yy++
3375
- for (const cmd of ['/help /settings /new /resume <id>', '/model <id> /provider <route> /rewind', '/stats /clear /cancel /quit']) {
3578
+ for (const cmd of ['/help /settings /new /resume <id>', '/branch /model <id> /provider <route> /rewind', '/stats /clear /cancel /quit']) {
3376
3579
  screen.text(x0 + 3, yy, cmd, makeStyle({ fg: t.text, bg: t.backgroundElement }))
3377
3580
  yy++
3378
3581
  }
@@ -3449,12 +3652,115 @@ export class App {
3449
3652
  }
3450
3653
  this.addHitRegion(picker.step === 'restore' ? 'rewind-option' : 'rewind-item', x0 + 1, y, w - 2, 1, { rewindIndex: i })
3451
3654
  }
3452
- const footer = picker.busy
3453
- ? tr('common.pleaseWait')
3454
- : picker.step === 'restore'
3455
- ? tr('common.selectConfirmBack')
3456
- : tr('common.selectContinueExit')
3457
- screen.text(x0 + 2, y0 + h - 2, truncateWidth(footer, innerW), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
3655
+ if (picker.busy) {
3656
+ screen.text(x0 + 2, y0 + h - 2, truncateWidth(tr('common.pleaseWait'), innerW), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
3657
+ } else {
3658
+ paintKeyHints(screen, x0 + 2, y0 + h - 2,
3659
+ picker.step === 'restore' ? tr('common.selectConfirmBack') : tr('common.selectContinueExit'),
3660
+ innerW, t)
3661
+ }
3662
+ }
3663
+
3664
+ _paintGitBranches(screen, cols, rows, railW = 0) {
3665
+ const picker = this.gitBranches
3666
+ if (!picker) return
3667
+ const t = THEME
3668
+ const region = overlayRegion(cols, railW)
3669
+ const w = Math.min(72, region - 4)
3670
+ const h = Math.min(rows - 2, 8 + Math.min(7, Math.max(1, picker.items.length)))
3671
+ const x0 = Math.max(0, Math.floor((region - w) / 2))
3672
+ const y0 = Math.max(0, Math.floor((rows - h) / 2))
3673
+ const box = makeStyle({ fg: t.text, bg: t.backgroundElement })
3674
+ const border = makeStyle({ fg: t.border, bg: t.backgroundElement })
3675
+ for (let y = y0; y < y0 + h; y++) screen.fill(x0, y, w, ' ', box)
3676
+ for (let i = 0; i < w; i++) {
3677
+ screen.set(x0 + i, y0, '─', border)
3678
+ screen.set(x0 + i, y0 + h - 1, '─', border)
3679
+ }
3680
+ for (let i = 0; i < h; i++) {
3681
+ screen.set(x0, y0 + i, '│', border)
3682
+ screen.set(x0 + w - 1, y0 + i, '│', border)
3683
+ }
3684
+ screen.set(x0, y0, '╭', border); screen.set(x0 + w - 1, y0, '╮', border)
3685
+ screen.set(x0, y0 + h - 1, '╰', border); screen.set(x0 + w - 1, y0 + h - 1, '╯', border)
3686
+ screen.text(x0 + 2, y0 + 1, tr('git.title'), makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
3687
+ const innerW = w - 4
3688
+ // The subtitle doubles as the action report: busy while an operation
3689
+ // runs, its failure, its one-line outcome, or the resting current-branch
3690
+ // reading — in that precedence, so a failed pull is never hidden by the
3691
+ // branch it was pulling.
3692
+ let subtitle
3693
+ if (picker.busy) subtitle = tr('common.working')
3694
+ else if (picker.error) subtitle = picker.error
3695
+ else if (picker.status) subtitle = picker.status
3696
+ else subtitle = tr('git.subtitle', { branch: picker.current || tr('git.detached'), path: picker.root })
3697
+ screen.text(x0 + 2, y0 + 2, truncateWidth(subtitle, innerW), makeStyle({
3698
+ fg: picker.error ? t.error : t.textMuted,
3699
+ bg: t.backgroundElement,
3700
+ }))
3701
+ const listTop = y0 + 4
3702
+ const listH = Math.max(1, h - 7)
3703
+ const start = picker.scroll
3704
+ const end = Math.min(picker.items.length, start + listH)
3705
+ if (picker.items.length === 0) {
3706
+ screen.text(x0 + 3, listTop, tr('git.empty'), makeStyle({ fg: t.textMuted, italic: true, bg: t.backgroundElement }))
3707
+ }
3708
+ for (let i = start; i < end; i++) {
3709
+ const y = listTop + (i - start)
3710
+ const row = picker.items[i]
3711
+ if (row.kind === 'header') {
3712
+ screen.text(x0 + 3, y, truncateWidth(row.label, innerW - 2), makeStyle({ fg: t.accent, bold: true, bg: t.backgroundElement }))
3713
+ continue
3714
+ }
3715
+ const selected = i === picker.selected
3716
+ const st = makeStyle({
3717
+ fg: selected ? t.primary : (row.current ? t.textMuted : t.text),
3718
+ bold: selected,
3719
+ bg: selected ? t.backgroundPanel : t.backgroundElement,
3720
+ })
3721
+ if (selected) screen.fill(x0 + 1, y, w - 2, ' ', st)
3722
+ // The `*` marker is git's own, so it needs no translation and reads the
3723
+ // same in every locale.
3724
+ const pointer = selected ? '▸ ' : ' '
3725
+ const marker = row.current ? '* ' : ' '
3726
+ screen.text(x0 + 2, y, truncateWidth(pointer + marker + row.name, innerW), st)
3727
+ this.addHitRegion('git-branch-item', x0 + 1, y, w - 2, 1, { branchIndex: i })
3728
+ }
3729
+ if (picker.busy) {
3730
+ screen.text(x0 + 2, y0 + h - 2, truncateWidth(tr('common.pleaseWait'), innerW), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
3731
+ } else {
3732
+ paintKeyHints(screen, x0 + 2, y0 + h - 2, tr('git.footer'), innerW, t)
3733
+ }
3734
+ }
3735
+ }
3736
+
3737
+ // Overlay footers and hint rows read as "key description · key description"
3738
+ // pairs. Painting such a line in one muted style let the keys sink into their
3739
+ // descriptions, so each pair's leading key token is repainted in the accent
3740
+ // color and bold while the descriptions and the ' · ' glue stay muted. The
3741
+ // split takes the first space of every ' · '-separated segment — a structure
3742
+ // every footer catalog follows by design — so a translated key word
3743
+ // ("Entrée") is highlighted exactly like "Enter".
3744
+ function paintKeyHints(screen, x, y, text, maxWidth, theme, base = null) {
3745
+ const keyStyle = makeStyle({ fg: theme.accent, bold: true, bg: theme.backgroundElement })
3746
+ const restStyle = base ?? makeStyle({ fg: theme.textMuted, bg: theme.backgroundElement })
3747
+ let cx = x
3748
+ const put = (chunk, style) => {
3749
+ if (chunk === '' || cx >= x + maxWidth) return
3750
+ const shown = truncateWidth(chunk, x + maxWidth - cx)
3751
+ if (shown === '') return
3752
+ screen.text(cx, y, shown, style)
3753
+ cx += displayWidth(shown)
3754
+ }
3755
+ const segments = text.split(' · ')
3756
+ for (let i = 0; i < segments.length; i++) {
3757
+ if (i > 0) put(' · ', restStyle)
3758
+ const gap = segments[i].indexOf(' ')
3759
+ if (gap < 0) put(segments[i], keyStyle)
3760
+ else {
3761
+ put(segments[i].slice(0, gap), keyStyle)
3762
+ put(segments[i].slice(gap), restStyle)
3763
+ }
3458
3764
  }
3459
3765
  }
3460
3766