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.
- package/README.md +13 -6
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +39 -6
- package/dsh-plugin.json +5 -1
- package/lib/diagnostics.js +120 -0
- package/lib/git-branch.js +157 -0
- package/lib/image.js +5 -1
- package/lib/index.js +904 -19
- package/lib/locales/ar.js +637 -560
- package/lib/locales/de.js +606 -529
- package/lib/locales/en.js +79 -0
- package/lib/locales/es.js +606 -529
- package/lib/locales/fr.js +606 -529
- package/lib/locales/id.js +608 -531
- package/lib/locales/it.js +606 -529
- package/lib/locales/ja.js +606 -529
- package/lib/locales/ko.js +606 -529
- package/lib/locales/nl.js +606 -529
- package/lib/locales/pl.js +620 -543
- package/lib/locales/pt-BR.js +606 -529
- package/lib/locales/ru.js +620 -543
- package/lib/locales/tr.js +606 -529
- package/lib/locales/vi.js +608 -531
- package/lib/locales/zh-CN.js +607 -530
- package/lib/locales/zh-TW.js +607 -530
- package/lib/mermaid-ascii.js +20 -1
- package/lib/mermaid.js +39 -20
- package/lib/providers.js +712 -0
- package/lib/splash-opencode.js +27 -9
- package/lib/std/command-list.js +2 -1
- package/lib/term.js +13 -1
- package/lib/ui.js +326 -20
- package/lib/util.js +49 -0
- package/lib/web-settings.js +182 -142
- package/package.json +5 -4
package/lib/splash-opencode.js
CHANGED
|
@@ -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
|
|
95
|
-
//
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 (
|
|
105
|
-
|
|
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
|
}
|
package/lib/std/command-list.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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 === '' && !
|
|
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 (
|
|
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(
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
: tr('common.selectContinueExit')
|
|
3457
|
-
|
|
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
|
|