-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrender.go
More file actions
1603 lines (1532 loc) · 52.5 KB
/
Copy pathrender.go
File metadata and controls
1603 lines (1532 loc) · 52.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ---------------------------------------------------------------------------
// Layout & rendering
//
// Top-to-bottom:
// header (3) brand + tagline row, cwd + tip row, separator
// viewport (N) the scrollable transcript
// posbar (1) scroll-position bar / "↓ N new" affordance
// banner (1) approval prompt (when pending)
// panel (?) active-tasks box (when a scout is in flight)
// inputbox (3) bordered chat input
// footer (2) line 1: state·model·approval·think | line 2: metrics·context
//
// The posbar is always reserved so scrolling up never reflows the transcript.
// ---------------------------------------------------------------------------
func envEnabled(names ...string) bool {
for _, name := range names {
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
case "1", "true", "yes", "on":
return true
}
}
return false
}
// These environment switches make the TUI usable in assistive/log-oriented
// environments without adding persisted settings that older cores reject.
func prefersReducedMotion() bool {
return envEnabled("CATCODE_REDUCED_MOTION", "REDUCED_MOTION")
}
// motionReduced reports whether UI animations should be suppressed (env or setting).
func (s *session) motionReduced() bool {
return prefersReducedMotion() || s.settings.ReducedMotion
}
func plainTerminalMode() bool {
return envEnabled("CATCODE_PLAIN", "CATCODE_NO_ALT_SCREEN")
}
var noColorANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`)
// stripANSI removes CSI sequences so callers can match plain text in styled output.
func stripANSI(s string) string {
return noColorANSIRe.ReplaceAllString(s, "")
}
// headerHeight is deliberately measured rather than hard-coded: compact
// terminals use a single header row, while normal terminals retain both rows.
func (s *session) headerHeight() int { return lipgloss.Height(s.renderHeader()) }
// viewChromeCache holds chrome strings built once per View so relayoutHeights
// measure-by-render does not double-build the animated input / panels.
type viewChromeCache struct {
header, footer, inputBox, activityShelf, goalPanel, mentionFlyout, positionBar, workingWave string
headerOK, footerOK, inputOK, shelfOK, goalOK, mentionOK, posOK, waveOK bool
}
func (s *session) beginViewChrome() { s.viewChrome = &viewChromeCache{} }
func (s *session) endViewChrome() { s.viewChrome = nil }
// relayoutHeights recomputes the viewport height to fit the current input-box
// height + panels and applies it. It is CHEAP: it does not re-render the
// transcript blocks (their content is unchanged; only the viewport's visible
// window height moves). Called after every input update so a growing
// multi-line input shrinks the viewport instead of pushing the footer off the
// bottom of the screen.
func (s *session) relayoutHeights() {
if !s.ready {
return
}
// Fixed-height optional panels (everything except the active-tasks panel,
// whose entry count we cap below to fit).
fixedExtra := 0
if s.coreLifecycle == coreFailed && s.hasConversation() {
fixedExtra++
}
if s.updateInfo != nil {
fixedExtra++
}
fixedExtra += s.mentionFlyoutHeight()
fixedExtra += s.activityShelfHeight() + s.oauthBannerHeight()
fixedExtra += s.goalProgressPanelHeight()
fixedExtra += s.workingWaveHeight()
// Space left for the viewport + the active-tasks panel, leaving 1 line of
// slack for v2's cursed renderer (it scrolls/overlaps when the view fills
// the terminal exactly).
avail := s.height - s.headerHeight() - s.positionBarHeight() - s.footerHeight() - fixedExtra - 1
// Legacy/detail renderer remains available to tests and explicit reports;
// the main View uses the unified shelf instead.
s.maxTaskRows = min(2, len(s.subProgress))
h := avail
if h < 0 {
h = 0 // panels fill the screen; hide the transcript rather than overflow
}
s.viewport.SetWidth(s.width)
s.viewport.SetHeight(h)
s.input.SetWidth(max(1, s.width-4))
}
// layout recomputes heights AND re-renders the transcript. Use it on events
// that change or re-wrap the blocks (terminal resize, task start/finish). For
// input-only changes (typing/pasting) use relayoutHeights() — re-rendering
// every keystroke is expensive.
func (s *session) layout() {
prevW := s.viewport.Width()
s.relayoutHeights()
// Wrap width is viewport.Width(); height-only changes (todo/scout/goal
// panels) must not wipe the finalized block cache.
if s.viewport.Width() != prevW {
s.invalidateAll()
}
s.refresh()
}
func (s *session) renderIntercomBanner() string {
i := s.pendingIntercom
sendKey, closeKey := s.keyHint("send"), s.keyHint("close")
if sendKey == "" {
sendKey = "send"
}
if closeKey == "" {
closeKey = "skip"
}
var msg string
if !s.intercomNudge.IsZero() && time.Since(s.intercomNudge) < 1500*time.Millisecond {
msg = fmt.Sprintf("⚠ type a reply below, then %s · %s to skip", sendKey, closeKey)
} else {
queue := ""
if n := 1 + len(s.intercomQueue); n > 1 {
queue = fmt.Sprintf(" [1 of %d]", n)
}
msg = fmt.Sprintf("❓ subagent %s%s asks: %s type reply + %s · %s skip", i.from, queue, truncate(i.message, max(1, s.width-60)), sendKey, closeKey)
}
return lipgloss.NewStyle().
Width(max(1, s.width-2)).MaxWidth(max(1, s.width)).
Background(lipgloss.Color(c.accent)).
Foreground(lipgloss.Color(c.bg)).
Bold(true).
Padding(0, 1).
Render(msg)
}
func (s *session) renderCoreFailureBanner() string {
if s.coreLifecycle != coreFailed || !s.hasConversation() {
return ""
}
msg := "core unavailable · r retry · q quit"
if s.coreFailure != "" && s.width >= 64 {
msg = truncate(s.coreFailure+" · r retry · q quit", max(1, s.width-2))
}
return lipgloss.NewStyle().MaxWidth(max(1, s.width)).
Foreground(lipgloss.Color(c.bg)).Background(lipgloss.Color(c.err)).Bold(true).
Render(" " + msg)
}
// renderHeader keeps fixed two-row geometry on normal terminals so mouse hit
// targets and transcript coordinates stay stable. The second row is metadata,
// rendered quieter than the workspace identity above it.
func (s *session) renderHeader() string {
if s.viewChrome != nil && s.viewChrome.headerOK {
return s.viewChrome.header
}
brand := accentStyle.Render("◆") + " " + boldBaseStyle.Render("Catalyst") + mutedStyle.Render(" / Code")
state, stateColor := "starting", c.secondary
switch {
case s.coreLifecycle == coreFailed:
state, stateColor = "core down", c.err
case s.busy:
state, stateColor = "working", c.accent
if s.goalState != nil && goalShowsProgressPanel(s.goalState.Phase, s.goalState.AutoDeploy) {
settled, total := s.goalProgressCounts()
state = fmt.Sprintf("goal · %s · %d/%d", goalProgressPhaseLabel(s.goalState.Phase, s.goalState.AutoDeploy), settled, total)
}
case s.authed:
state, stateColor = "ready", c.success
case len(s.models) > 0:
state, stateColor = "no key", c.warn
}
right := statusDot(stateColor) + " " + lipgloss.NewStyle().Foreground(lipgloss.Color(stateColor)).Bold(true).Render(state)
if len(s.models) > 0 && s.modelIdx >= 0 && s.modelIdx < len(s.models) && s.width >= 42 {
right += mutedStyle.Render(" · " + truncate(s.models[s.modelIdx].ID, max(8, s.width/3)))
}
out := fitRow(s.width, " "+brand, right+" ")
if s.width >= 48 && s.height >= 12 {
project := "PROJECT —"
if s.cwd != "" {
project = "PROJECT " + truncatePath(s.cwd, max(12, s.width/2-12))
}
model := "MODEL —"
if len(s.models) > 0 && s.modelIdx >= 0 && s.modelIdx < len(s.models) {
model = "MODEL " + truncate(s.models[s.modelIdx].ID, max(10, s.width/3))
}
out += "\n" + fitRow(s.width, " "+mutedStyle.Render(project), mutedStyle.Render(model)+" ")
}
if s.viewChrome != nil {
s.viewChrome.header = out
s.viewChrome.headerOK = true
}
return out
}
// renderPositionBar: a thin scroll affordance. Pinned to the bottom it is a
// subtle dim rule; scrolled up it becomes an accent bar telling the user how
// many newer lines are hidden below and how to jump back.
func (s *session) renderPositionBar() string {
if s.viewChrome != nil && s.viewChrome.posOK {
return s.viewChrome.positionBar
}
w := s.width
if w < 1 {
w = 1
}
// lines hidden below the current viewport window (0 when pinned to bottom)
below := s.viewport.TotalLineCount() - s.viewport.YOffset() - s.viewport.VisibleLineCount()
if below < 0 {
below = 0
}
var out string
if below > 0 {
pct := int(s.viewport.ScrollPercent() * 100)
msg := fmt.Sprintf("↓ %d new · %d%% · PgDn scroll · Ctrl+End jump", below, pct)
out = lipgloss.NewStyle().
Width(max(1, w-2)).MaxWidth(w).
Background(lipgloss.Color(c.accent)).
Foreground(lipgloss.Color(c.bg)).
Bold(true).
Padding(0, 1).
Render(msg)
}
if s.viewChrome != nil {
s.viewChrome.positionBar = out
s.viewChrome.posOK = true
}
return out
}
func (s *session) positionBarHeight() int {
if s.renderPositionBar() == "" {
return 0
}
return 1
}
// approvalBanner: a full-width sticky bar shown while a decision is pending.
// The head reuses the per-tool primitives (icon + name + parsed keyarg) so the
// human approves the actual target ("src/main.rs · 3 replacements") instead
// of a raw JSON blob. For write/edit/patch a unified-diff preview renders
// below so the decision is on the real change, not the search/replace blobs.
func (s *session) renderApprovalBanner() string {
a := s.pendingApproval
var actions []string
if key := s.keyHint("approve"); key != "" {
actions = append(actions, "["+key+"] once")
}
if key := s.keyHint("deny"); key != "" {
actions = append(actions, "["+key+"] deny")
}
if key := s.keyHint("approve_always"); key != "" {
actions = append(actions, "["+key+"] type")
}
controls := strings.Join(actions, " · ")
avail := s.width - lipgloss.Width(controls) - 24
if avail < 8 {
avail = 8
}
summary := truncate(approvalSummary(a.tool, a.args), avail)
// The approval banner is the one place the UI asks for a decision, so it gets
// the Catalyst warn (amber) as a solid accent bar — the highest-contrast
// surface in the app, impossible to miss while scrolling.
msg := "⚠ approval required " + toolIcon(a.tool) + " " + toolDisplayName(a.tool) + " " + summary
if controls != "" {
msg += " " + controls
}
// A non-empty composer disables the Y/N/A decision keys (they'd otherwise
// fire mid-typing). The composer placeholder explains this only when it's
// EMPTY — useless exactly when the keys are dead — so say it here, on the
// always-visible banner, whenever a draft is present.
if strings.TrimSpace(s.input.Value()) != "" {
msg += " · clear input to answer"
}
// Elapsed waiting time: after a few seconds it reassures the user the
// request is live and how long they've been blocking the turn.
if !a.receivedAt.IsZero() {
if d := time.Since(a.receivedAt); d >= 5*time.Second {
msg += " · waiting " + d.Truncate(time.Second).String()
}
}
banner := lipgloss.NewStyle().
Width(max(1, s.width-2)).MaxWidth(max(1, s.width)).
Background(lipgloss.Color(c.warn)).
Foreground(lipgloss.Color(c.bg)).
Bold(true).
Padding(0, 1).
Render(msg)
if strings.TrimSpace(a.diff) != "" {
banner += "\n" + s.renderApprovalDiff(a)
}
return banner
}
func (s *session) renderApprovalDiff(a *approvalPrompt) string {
if !a.expanded {
return renderDiffPanel(a.diff, false, s.width, s.keyHint("toggle_tool_output"))
}
all := strings.Split(renderDiffPanel(a.diff, true, s.width, s.keyHint("toggle_tool_output")), "\n")
capRows := s.height / 2
if capRows < 3 {
capRows = 3
}
if capRows > len(all) {
capRows = len(all)
}
maxScroll := len(all) - capRows
if a.diffScroll > maxScroll {
a.diffScroll = maxScroll
}
if a.diffScroll < 0 {
a.diffScroll = 0
}
view := strings.Join(all[a.diffScroll:a.diffScroll+capRows], "\n")
if len(all) > capRows {
view += "\n" + mutedStyle.Render(fmt.Sprintf("│ diff rows %d–%d/%d · PgUp/PgDn scroll", a.diffScroll+1, a.diffScroll+capRows, len(all)))
}
return view
}
// renderFooter is a quiet command deck. The composer remains the primary
// surface; controls and telemetry use typography instead of another filled bar.
func (s *session) renderFooter() string {
if s.viewChrome != nil && s.viewChrome.footerOK {
return s.viewChrome.footer
}
left := s.footerControlHint()
if toast := s.renderToast(); toast != "" {
left = toast
} else {
left = keyHintStyle.Render(left)
}
right := s.renderContext()
var lines []string
if s.width < 48 {
// Compact mode preserves the one action that can be taken now and the
// current token total. It intentionally drops only the context maximum.
lines = append(lines, " "+keyHintStyle.Render(s.primaryFooterHint()))
var maxToks uint64
if len(s.models) > 0 && s.modelIdx >= 0 && s.modelIdx < len(s.models) {
maxToks = uint64(s.models[s.modelIdx].ContextWindow)
}
pct := 0
if maxToks > 0 {
pct = min(100, int(float64(s.contextTokens)/float64(maxToks)*100))
if pct == 0 && s.contextTokens > 0 {
pct = 1
}
}
bar := renderContextBar(float64(pct)/100, 10)
lines = append(lines, " "+bar+mutedStyle.Render(fmt.Sprintf(" %d%% · %s", pct, compactTokens(s.contextTokens))))
} else {
lines = append(lines, fitRow(max(1, s.width), " "+left, right+" "))
}
if s.settings.FooterMetrics && s.height >= 16 {
model := "no model"
if len(s.models) > 0 && s.modelIdx >= 0 && s.modelIdx < len(s.models) {
model = s.models[s.modelIdx].ID
}
if metrics := s.renderMetrics(); metrics != "" {
model += " · " + metrics
}
lines = append(lines, mutedStyle.Render(" "+truncate(model, max(1, s.width-1))))
}
out := strings.Join(lines, "\n")
if s.viewChrome != nil {
s.viewChrome.footer = out
s.viewChrome.footerOK = true
}
return out
}
func (s *session) primaryFooterHint() string {
if s.pendingApproval != nil {
return s.keyHint("approve") + " allow · " + s.keyHint("deny") + " deny"
}
if s.busy {
return s.keyHint("send") + " queue · " + s.keyHint("close") + " abort"
}
return s.keyHint("send") + " send"
}
func (s *session) footerControlHint() string {
h := s.newFooterHelp(max(1, s.width))
return h.ShortHelpView(s.footerHelpBindings())
}
func (s *session) newFooterHelp(width int) help.Model {
h := help.New()
h.ShortSeparator = " · "
h.Ellipsis = "…"
h.Styles = catalystHelpStyles()
h.SetWidth(width)
return h
}
func (s *session) footerHelpBindings() []key.Binding {
switch {
case s.pendingApproval != nil:
return []key.Binding{
s.bindingFor("approve", "allow once"),
s.bindingFor("deny", "deny"),
s.bindingFor("approve_always", "always allow type"),
}
case s.busy:
return []key.Binding{
s.bindingFor("send", "queue"),
s.bindingFor("close", "abort"),
s.bindingFor("steer", "steer"),
}
default:
return []key.Binding{
s.bindingFor("send", "send"),
s.bindingFor("newline", "newline"),
s.bindingFor("command_palette", "commands"),
}
}
}
// composerHintLine is a dim second line inside the composer while busy/queued/
// approval is active and the user is already typing (placeholder is hidden).
func (s *session) composerHintLine(innerW int) string {
h := s.newFooterHelp(innerW)
switch {
case s.pendingApproval != nil:
out := h.ShortHelpView([]key.Binding{
s.bindingFor("approve", "allow once"),
s.bindingFor("deny", "deny"),
s.bindingFor("approve_always", "always allow type"),
})
extra := h.Styles.ShortDesc.Inline(true).Render("clear input first")
if out == "" {
return extra
}
return out + h.Styles.ShortSeparator.Inline(true).Render(h.ShortSeparator) + extra
case s.queued != nil:
out := h.ShortHelpView([]key.Binding{s.bindingFor("close", "cancels queued")})
prefix := h.Styles.ShortDesc.Inline(true).Render("queue full")
if out == "" {
return prefix
}
return prefix + h.Styles.ShortSeparator.Inline(true).Render(h.ShortSeparator) + out
case s.busy:
return h.ShortHelpView([]key.Binding{
s.bindingFor("send", "queues"),
s.bindingFor("close", "aborts"),
s.bindingFor("steer", "steers"),
})
default:
return ""
}
}
// renderMetrics builds the throughput string for the footer's second line:
// TPS (rounded to the nearest integer) and TTFT, plus the prefix-cache hit rate
// (e.g. "42 tok/s · 180ms ttft · 87% cached"). During an in-flight stream the
// core may emit tps_est, an approximate live throughput based on streamed text;
// final metrics use tps, the provider-reported real token count.
//
// The cache rate has a wrinkle: the live mid-stream metrics event omits
// cached_tokens — it only lands in the turn-end metrics. So while a turn is in
// flight there's no cache number for *this* turn yet. We fall back to the
// previous turn's measured rate (captured in s.lastCachePct by the metrics
// handler) and prefix it with "~" so it reads as "from last turn", not a live
// reading. Once the turn-end metrics arrive (cached_tokens present), the fresh,
// un-tilde'd rate is shown.
func (s *session) renderMetrics() string {
var m map[string]json.RawMessage
haveLive := len(s.lastMetrics) > 0 && json.Unmarshal(s.lastMetrics, &m) == nil
var out string
if haveLive {
tps := get(m, "tps")
approx := false
if tps == "" || tps == "null" {
tps = get(m, "tps_est")
approx = tps != "" && tps != "null"
}
if tps != "" && tps != "null" {
// Round to the nearest integer so the footer reads "71 tok/s"
// rather than "71.123132991239 tok/s". Prefix live estimates with
// "~" so they are useful in-flight without being confused for the
// final provider-usage-derived TPS.
prefix := ""
if approx {
prefix = "~"
}
if f, err := strconv.ParseFloat(tps, 64); err == nil {
out = fmt.Sprintf("%s%d tok/s", prefix, int(math.Round(f)))
} else {
out = fmt.Sprintf("%s%s tok/s", prefix, tps)
}
}
// Time-to-first-token for this turn (latency, not throughput).
if ttft := get(m, "ttft_ms"); ttft != "" && ttft != "null" {
if out != "" {
out += fmt.Sprintf(" · %sms ttft", ttft)
} else {
out = fmt.Sprintf("%sms ttft", ttft)
}
}
}
// Prefix-cache hit rate. cached_tokens present in the live metrics ⇒ this
// is the turn-end number (fresh); absent ⇒ mid-stream, so carry the last
// turn's rate and mark it "~" so it isn't mistaken for a live reading.
fresh := false
if haveLive {
c := get(m, "cached_tokens")
fresh = c != "" && c != "null" && c != "0"
}
if s.lastCachePct > 0 {
cacheStr := fmt.Sprintf("%d%% cached", s.lastCachePct)
if !fresh {
cacheStr = "~" + cacheStr
}
if out != "" {
out += " · " + cacheStr
} else {
out = cacheStr
}
}
// Context-management reclaim: cumulative tokens freed by digest + compaction
// and the current rolling summary's size, shown next to the cache stat so the
// cost/benefit of compaction is visible at a glance.
if s.tokensSaved > 0 {
if out != "" {
out += " · "
}
out += fmt.Sprintf("%s saved", compactTokens(s.tokensSaved))
}
if s.summaryChars > 0 {
if out != "" {
out += " · "
}
out += fmt.Sprintf("summary %s chars", compactTokens(uint64(s.summaryChars)))
}
// Live Umans account-wide concurrency (used/limit) goes FIRST, ahead of
// tps/ttft/cached, so it reads "Conc 3/8 · 42 tok/s · …". It is shown even
// when idle (no turn metrics) because it is polled independently every few
// seconds — that is the "always live" part. Hidden when not Umans / fetch
// failed; limit renders ∞ when the plan is unlimited.
if conc := s.renderUmansConc(); conc != "" {
if out != "" {
out = conc + " · " + out
} else {
out = conc
}
}
return out
}
// renderUmansConc renders the live concurrency field for the footer, e.g.
// "Conc 3/8". Returns "" (hide) when there is no usage reading (not Umans,
// no key, or the /v1/usage fetch failed), OR when the selected model does NOT
// route to the Umans provider the poll is tracking — a Gemini/OpenAI model
// selected means no conc field, even if a Umans provider is logged in. A null
// limit (unlimited plan) renders as ∞.
func (s *session) renderUmansConc() string {
if s.umansConcUsed == nil || s.umansConcProvider == "" {
return ""
}
// Only show when the selected model routes to this Umans provider.
if s.modelIdx < 0 || s.modelIdx >= len(s.models) {
return ""
}
if s.models[s.modelIdx].Provider != s.umansConcProvider {
return ""
}
if s.umansConcLimit == nil {
return fmt.Sprintf("Conc %d/∞", *s.umansConcUsed)
}
return fmt.Sprintf("Conc %d/%d", *s.umansConcUsed, *s.umansConcLimit)
}
// fitRow places left flush and right flush, padding the gap.
func fitRow(width int, left, right string) string {
if width < 1 {
return ""
}
tl := lipgloss.Width(left)
if tl > width {
return lipgloss.NewStyle().MaxWidth(width).Render(left)
}
tr := lipgloss.Width(right)
gap := width - tl - tr
if gap < 0 {
return lipgloss.NewStyle().MaxWidth(width).Render(left)
}
return left + strings.Repeat(" ", gap) + right
}
// compactTokens formats a token count compactly: 940 → "940", 1200 → "1.2k".
func compactTokens(n uint64) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
if n < 1_000_000 {
return fmt.Sprintf("%.1fk", float64(n)/1000)
}
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
}
// cwdBasename returns the last path component of the working dir, for the header.
func cwdBasename() string {
wd, err := os.Getwd()
if err != nil {
return ""
}
b := filepath.Base(wd)
if b == "." || b == string(filepath.Separator) {
return ""
}
return b
}
// cwdDisplay returns the working dir as a short home-relative path (~/rest),
// falling back to the basename when it's long or off-home. Shown in the header.
func cwdDisplay() string {
wd, err := os.Getwd()
if err != nil {
return ""
}
if abs, err := filepath.Abs(wd); err == nil {
wd = abs
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
if wd == home {
return "~"
}
if rel, err := filepath.Rel(home, wd); err == nil && !strings.HasPrefix(rel, "..") {
return "~/" + filepath.ToSlash(rel)
}
}
return cwdBasename()
}
// renderContext builds the right-aligned context-budget string: "7% 13.7k/128k"
// using the current model's context window and the cumulative session tokens.
func (s *session) renderContext() string {
var maxToks uint64
if len(s.models) > 0 && s.modelIdx >= 0 && s.modelIdx < len(s.models) {
maxToks = uint64(s.models[s.modelIdx].ContextWindow)
}
cur := s.contextTokens
if maxToks == 0 {
return compactTokens(cur) + " tok"
}
pct := int(float64(cur) / float64(maxToks) * 100)
if pct < 1 && cur > 0 {
pct = 1
}
if pct > 100 {
pct = 100
}
// A 10-cell fill bar tinted by context pressure: green < 60%, amber < 85%,
// red ≥ 85% — so a glance at the footer shows how full the window is.
const cells = 10
filled := cells * pct / 100 // truncate so sub-cell pressure stays empty
ratio := float64(filled) / float64(cells)
bar := renderContextBar(ratio, cells)
return bar + " " + mutedStyle.Render(fmt.Sprintf("%d%% %s/%s", pct, compactTokens(cur), compactTokens(maxToks)))
}
// composerPlaceholder returns the empty-input hint, contextualized for busy /
// approval / queue so in-flight controls aren't invisible.
func (s *session) composerPlaceholder() string {
if s.showingSplash() {
return "Starting core and checking credentials…"
}
if s.coreLifecycle == coreFailed {
return "Core unavailable — r retry · q quit · see the recovery panel"
}
if s.pendingApproval != nil {
return "Type a follow-up, or clear input to use the approval keys…"
}
if s.busy {
send, close := s.keyHint("send"), s.keyHint("close")
if send == "" {
send = "Send"
}
if close == "" {
close = "Close"
}
if s.queued != nil {
return "Queue full — " + close + " cancels queued · again aborts…"
}
steer := s.keyHint("steer")
if steer == "" {
steer = "Ctrl+Enter"
}
return send + " queues · " + close + " aborts · " + steer + " steers · / commands"
}
if !s.canSend() {
return "Log in first — /login · / for commands · ? help"
}
if s.input.Placeholder != "" {
return s.input.Placeholder
}
return "Chat with the agent… (/ commands · ? help)"
}
// keyLabel returns the live binding string for an action, or "".
func (s *session) keyLabel(action string) string {
if s.keybinds == nil {
return ""
}
return s.keybinds[action]
}
// renderInputBox presents the composer as a labelled command surface. It grows
// downward with wrapped input while its MESSAGE label and SEND affordance stay
// in fixed positions, making the primary action obvious at a glance.
func (s *session) renderInputBox() string {
if s.viewChrome != nil && s.viewChrome.inputOK {
return s.viewChrome.inputBox
}
out := s.renderInputBoxUncached()
if s.viewChrome != nil {
s.viewChrome.inputBox = out
s.viewChrome.inputOK = true
}
return out
}
func (s *session) renderInputBoxUncached() string {
w := s.width
if w < 1 {
w = 1
}
if w < 8 {
return lipgloss.NewStyle().MaxWidth(w).Render(s.inputContent(w))
}
// The composer is a rounded surface card. Border + horizontal padding consume
// four cells; the "❯ " prompt prefix consumes another two on text lines.
cardInnerW := w - 4
textW := cardInnerW - 2
// Attachment chips sit above the typed text so pasted images are visible
// even when the text field is empty (image-only send).
var chipLine string
if n := len(s.pendingImages); n > 0 {
parts := make([]string, 0, n)
for i := 0; i < n; i++ {
parts = append(parts, s.pendingImageLabel(i))
}
chip := strings.Join(parts, " ")
// Hint for detaching — only when there's room.
detach := s.keyHint("detach_image")
hint := ""
if detach != "" {
hint = " " + detach + " remove"
}
if lipgloss.Width(chip)+lipgloss.Width(hint) <= cardInnerW {
chipLine = accentStyle.Render(chip) + mutedStyle.Render(hint)
} else {
chipLine = accentStyle.Render(truncate(chip, cardInnerW))
}
}
content := s.inputContent(textW)
var lines []string
if chipLine != "" {
lines = append(lines, chipLine)
}
lines = append(lines, strings.Split(content, "\n")...)
// Busy / approval hint under the typed text when the box has content so
// controls stay discoverable even after the placeholder is gone.
if hint := s.composerHintLine(cardInnerW); hint != "" && s.input.Value() != "" {
lines = append(lines, hint)
}
// A static perimeter is intentional: it keeps focus calm while streamed
// content changes and gives every terminal the same composer geometry.
return s.renderComposerStatic(w, cardInnerW, textW, lines)
}
// renderComposerStatic draws a compact focus frame. The label identifies the
// mode; actionable key guidance remains in the footer instead of decorating
// both ends of the border.
func (s *session) renderComposerStatic(w, cardInnerW, textW int, lines []string) string {
prompt := accentStyle.Render("❯")
label := accentStyle.Render(" compose ")
middle := max(0, w-lipgloss.Width(label)-2)
var out strings.Builder
out.WriteString(railStyle.Render("╭"))
out.WriteString(label)
out.WriteString(railStyle.Render(strings.Repeat("─", middle) + "╮"))
for i, ln := range lines {
out.WriteByte('\n')
row := " " + ln
if i == 0 {
row = prompt + " " + ln
}
if gap := cardInnerW - lipgloss.Width(row); gap > 0 {
row += strings.Repeat(" ", gap)
}
out.WriteString(railStyle.Render("│ "))
out.WriteString(row)
out.WriteString(railStyle.Render(" │"))
}
out.WriteByte('\n')
out.WriteString(railStyle.Render("╰" + strings.Repeat("─", max(0, w-2)) + "╯"))
return out.String()
}
// hexRGB parses a #RRGGBB string into its RGB components.
func hexRGB(hex string) [3]int {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 {
return [3]int{}
}
n, err := strconv.ParseUint(hex, 16, 32)
if err != nil {
return [3]int{}
}
return [3]int{int(n >> 16 & 255), int(n >> 8 & 255), int(n & 255)}
}
// blendRGB linearly interpolates between base and target by t∈[0,1].
func blendRGB(base, target [3]int, t float64) [3]int {
return [3]int{
int(math.Round(float64(base[0]) + float64(target[0]-base[0])*t)),
int(math.Round(float64(base[1]) + float64(target[1]-base[1])*t)),
int(math.Round(float64(base[2]) + float64(target[2]-base[2])*t)),
}
}
// workingWave animation tuning. The travel cycle is short enough to read as
// motion at the 10 FPS busy clock; the slower breath keeps long runs from
// looking metronomic.
const (
workingWaveCycle = 1600 * time.Millisecond // one full wave travel
workingWaveBreath = 2500 * time.Millisecond // amplitude breathing cycle
)
// workingWaveRamp maps a 0..1 level to a sparkline glyph.
var workingWaveRamp = []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
// renderWorkingWave draws the one-line "agent is working" pulse directly above
// the composer: a full-width sparkline whose cell heights follow two traveling
// sine waves, colored per cell from the dim rail up to the accent at the
// crests (aurora / audio-waveform feel). The busy clock (busyFrameTick)
// re-renders View ~10x/s while busy, so the time-based phase animates without
// a dedicated ticker.
func (s *session) renderWorkingWave() string {
if s.viewChrome != nil && s.viewChrome.waveOK {
return s.viewChrome.workingWave
}
out := s.renderWorkingWaveUncached()
if s.viewChrome != nil {
s.viewChrome.workingWave = out
s.viewChrome.waveOK = true
}
return out
}
func (s *session) renderWorkingWaveUncached() string {
if !s.busy {
return ""
}
w := s.width
if w < 1 {
w = 1
}
cells := make([]string, w)
if s.motionReduced() {
// Static stand-in: a steady dim mid-level line, no time-based phase.
mid := string(workingWaveRamp[len(workingWaveRamp)/2])
for x := range cells {
cells[x] = mutedStyle.Render(mid)
}
return strings.Join(cells, "")
}
phase := float64(time.Now().UnixNano()%int64(workingWaveCycle)) / float64(int64(workingWaveCycle))
breath := float64(time.Now().UnixNano()%int64(workingWaveBreath)) / float64(int64(workingWaveBreath))
amp := 0.7 + 0.3*math.Sin(2*math.Pi*breath)
l1 := float64(w) / 2.5
l2 := float64(w) / 5
base := hexRGB(c.railDim)
accent := hexRGB(c.accent)
for x := 0; x < w; x++ {
v := 0.55*math.Sin(2*math.Pi*(float64(x)/l1)-2*math.Pi*phase) +
0.45*math.Sin(2*math.Pi*(float64(x)/l2)+2*math.Pi*phase*0.6)
level := (v + 1) / 2 * amp
// Fade the outer ~2 cells so the wave melts into the margins.
if edge := math.Min(float64(x), float64(w-1-x)) / 2; edge < 1 {
level *= edge
}
level = math.Min(math.Max(level, 0), 1)
ri := int(level*float64(len(workingWaveRamp)-1) + 0.5)
rgb := blendRGB(base, accent, level)
cells[x] = lipgloss.NewStyle().
Foreground(lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", rgb[0], rgb[1], rgb[2]))).
Render(string(workingWaveRamp[ri]))
}
return strings.Join(cells, "")
}
func (s *session) workingWaveHeight() int {
wv := s.renderWorkingWave()
if wv == "" {
return 0
}
return lipgloss.Height(wv)
}
// maxInputLines caps the input box height: a very long message shows a
// cursor-centered window (with … markers) instead of consuming the screen.
const maxInputLines = 5
// inputContent renders the chat input value soft-wrapped to width w, with the
// textinput cursor cell placed on the correct wrapped line. Returns the
// placeholder when the value is empty. textinput v2 no longer exposes its
// internal Cursor (SetChar/View) or top-level TextStyle/PlaceholderStyle
// fields, so composer text and cursor colors are derived directly from the
// active theme instead of inheriting the terminal's default grey.
func (s *session) inputContent(w int) string {
if w < 1 {
w = 1
}
value := s.input.Value()
// Active style state depends on focus; v2 keeps Focused()/Styles().
st := s.input.Styles()
active := st.Focused
if !s.input.Focused() {
active = st.Blurred
}
if value == "" {
ph := s.composerPlaceholder()
// When a subagent is waiting on an intercom reply, make it obvious the
// chat box below is where you type it (the banner alone reads as
// "press ↵ to reply", which leads users to hit Enter on an empty box).
if s.pendingIntercom != nil {
ph = "Reply to " + s.pendingIntercom.from + "…"
}
if ph == "" {
return ""
}
return active.Placeholder.Render(truncateFit(ph, w))
}
pos := inputPosition(s.input)
r := []rune(value)
if pos < 0 {
pos = 0
}
if pos > len(r) {
pos = len(r)
}
before := r[:pos]
after := r[pos:] // after[0] is the char under the cursor (if any)
// beforeLines are the display lines strictly above the cursor's line.
// wrapRunesMultiline splits on literal '\n' first, then width-wraps each
// segment, so typed/pasted line breaks render as their own rows instead
// of being treated as width-1 runes (which broke the box + cursor math).
beforeLines := wrapRunesMultiline(before, w)
cLine := len(beforeLines) - 1
cCol := len(beforeLines[cLine])
// If the last before-line is exactly full, the cursor wraps to a fresh line.
if cCol >= w {
beforeLines = append(beforeLines, []rune{})
cLine++
cCol = 0
}
// The cursor cell + the remainder after it. When the cursor sits directly on
// a line break, show an empty cell and force everything that follows onto
// subsequent display lines (the '\n' just ends the current line).
curChar := " "
rest := []rune(nil)
newlineConsumed := false
if len(after) > 0 {
if after[0] == '\n' {
newlineConsumed = true
rest = after[1:]
} else {