Files
correx/apps/tui-go/internal/app/view.go
T
kami d247b19608 tui: migrate Bubble Tea v1 → v2 (charm.land), enable Shift+Enter
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).

Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
  retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
  carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).

Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:59:55 +00:00

1000 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"image/color"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
const (
statusH = 1
footerH = 1
inputH = 4 // rounded box: 2 borders + 2 content lines
// staleEventThresholdMs: past this gap since the last event, an *active* session's
// last-event clock turns warn-colored — the "thinking vs hung" tell (§2).
staleEventThresholdMs = 30_000
// narrowWidth: below this the side-by-side panels become unreadable, so the main
// area collapses (single column / vertical stack) and the chrome goes compact.
narrowWidth = 96
)
// narrow reports whether the terminal is too slim for the full side-by-side layout
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }
// View renders the whole frame purely from the model (immediate-mode). In
// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so
// render() builds the string and View() wraps it with the screen settings.
func (m Model) View() tea.View {
return tea.View{Content: m.render(), AltScreen: true}
}
// render builds the frame string.
func (m Model) render() string {
if m.quitting {
return ""
}
if m.width < 20 || m.height < 8 {
return m.theme.Screen.Render("correx — terminal too small")
}
ds := m.displayState()
bottom, bottomH := m.renderInput(), m.inputBarHeight()
switch {
case ds == StateApproval:
bottomH = m.approvalBandHeight()
bottom = m.renderApprovalBand(bottomH)
case ds == StateIdle:
// Launcher: the input is centered inside the main area, so there's no bottom bar.
bottom, bottomH = "", 0
}
mainH := m.height - statusH - footerH - bottomH
if mainH < 3 {
mainH = 3
}
sections := []string{m.renderStatus(), m.renderMain(mainH)}
if bottomH > 0 {
sections = append(sections, bottom)
}
sections = append(sections, m.renderFooter())
base := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Stash the base so center() can composite a modal over a dimmed copy of it.
m.lastBase = base
if m.displayState() == StateClarification {
return m.center(m.clarificationModal())
}
if m.displayState() == StateWorkflowPropose {
return m.center(m.proposeModal())
}
if m.overlay != OverlayNone {
return m.renderOverlay(base)
}
return base
}
// --- top status bar ---
func (m Model) renderStatus() string {
t := m.theme
bg := t.P.BgPanel
span := func(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
}
nrw := m.narrow()
sepStr := " · "
if nrw {
sepStr = " · "
}
sep := span(sepStr, t.P.Faint)
parts := []string{span("correx", t.P.Dim)}
if m.connected {
parts = append(parts, span("●", t.P.OK)+span(" connected", t.P.Dim))
} else if m.reconnecting {
parts = append(parts, span("◌", t.P.Warn)+span(" reconnecting", t.P.Warn))
} else {
parts = append(parts, span("○", t.P.Bad)+span(" disconnected", t.P.Bad))
}
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
parts = append(parts, span(s.Name, t.P.FgStrong))
if s.CurrentStage != "" && m.sbShow("stage") {
parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2))
}
if s.Status != "" && m.sbShow("status") {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
}
if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") {
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
}
}
if m.sbShow("model") {
model := m.currentModel
if model == "" {
model = "no model"
}
if nrw {
parts = append(parts, span(model, t.P.Fg))
} else {
loc := "local"
if m.providerType == "REMOTE" {
loc = "remote"
}
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
}
}
left := strings.Join(parts, sep)
var right []string
// Last-event clock (§2): always visible in-session, so a silent agent is never
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
// active session has gone quiet past the stale threshold.
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") {
gap := nowMillis() - s.LastEventAt
clockFg := t.P.Faint
if s.Active && gap > staleEventThresholdMs {
clockFg = t.P.Warn
}
label := "last event " + agoLabel(gap)
if nrw { // compact: "◷ 3s" instead of "last event 3s ago"
label = "◷ " + strings.TrimSuffix(agoLabel(gap), " ago")
}
right = append(right, span(label, clockFg))
}
if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") {
right = append(right, span(g, t.P.Faint))
}
if s := m.session(m.selectedID); s != nil && s.Active {
spin := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())
if nrw {
right = append(right, spin)
} else {
right = append(right, spin+span(" active", t.P.Accent2))
}
}
if m.bgUpdates > 0 {
label := itoa(m.bgUpdates) + " elsewhere"
if nrw {
label = itoa(m.bgUpdates) + "↗"
}
right = append(right, span(label, t.P.Warn))
}
if len(right) == 0 {
return m.fill(left, m.width, bg)
}
return m.justify(left, strings.Join(right, sep), m.width, bg)
}
// gaugeText renders the live VRAM/GPU/RAM gauge, omitting any unavailable field.
func (m Model) gaugeText() string {
var parts []string
if m.gpuUsedMB != nil && m.gpuTotalMB != nil {
parts = append(parts, "VRAM "+itoa(int(*m.gpuUsedMB))+"/"+itoa(int(*m.gpuTotalMB))+"M")
}
if m.gpuUtil != nil {
parts = append(parts, "GPU "+itoa(*m.gpuUtil)+"%")
}
if m.sysRamUsedMB != nil && m.sysRamTotalMB != nil {
parts = append(parts, "RAM "+itoa(int(*m.sysRamUsedMB))+"/"+itoa(int(*m.sysRamTotalMB))+"M")
}
if m.ramMB != nil {
parts = append(parts, "proc "+itoa(int(*m.ramMB))+"M")
}
return strings.Join(parts, " ")
}
// agoLabel renders a millisecond age as a compact "Ns ago" / "Nm ago" / "Nh ago" /
// "Nd ago". Drives the last-event clock (§2) — the live signal that distinguishes a
// thinking agent from a hung one.
func agoLabel(deltaMs int64) string {
if deltaMs < 0 {
deltaMs = 0
}
s := deltaMs / 1000
switch {
case s < 60:
return itoa(int(s)) + "s ago"
case s < 3600:
return itoa(int(s/60)) + "m ago"
case s < 86400:
return itoa(int(s/3600)) + "h ago"
default:
return itoa(int(s/86400)) + "d ago"
}
}
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] }
// caretVisible blinks the insert caret roughly twice a second.
func (m Model) caretVisible() bool { return (m.frame/4)%2 == 0 }
// --- bottom footer hints ---
func (m Model) renderFooter() string {
t := m.theme
bg := t.P.BgDeep
hint := func(key, label string) string {
k := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Bold(true).Render(key)
l := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" " + label)
return k + l
}
gap := lipgloss.NewStyle().Background(bg).Render(" ")
nrw := m.narrow()
var hints []string
switch {
case m.editMode == ModeInsert:
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
if m.inputMode != ModeFilter {
hints = append(hints, hint("^J", "newline"))
}
// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
if m.inputMode == ModeRouter && m.inputBuffer == "" {
hints = append(hints, hint("/", "cmds"))
}
if m.inputMode == ModeRouter {
hints = append(hints, hint("@", "files"))
}
case m.displayState() == StateApproval:
// Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
case m.displayState() == StateClarification:
// The question form owns its keys; the footer mirrors the essentials.
hints = []string{hint("←→", "option"), hint("↑↓", "question"), hint("spc", "select"), hint("enter", "submit"), hint("esc", "later")}
case m.displayState() == StateWorkflowPropose:
// The picker owns its keys; the footer mirrors the essentials.
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
case m.displayState() == StateIdle:
if nrw {
hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
}
default: // StateInSession
if nrw {
hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
} else {
// Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`,
// so the footer keeps the high-traffic keys + the new transcript nav/copy.
hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
}
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
}
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)"))
}
}
left := strings.Join(hints, gap)
// The "Soft · blue" badge is decoration — drop it when slim so the hints get the room.
if nrw {
return m.fill(left, m.width, bg)
}
right := lipgloss.NewStyle().Foreground(t.P.Dim).Background(bg).Render("Soft") +
lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" · ") +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render("blue")
return m.justify(left, right, m.width, bg)
}
// --- main split ---
func (m Model) renderMain(h int) string {
if m.displayState() == StateIdle {
return m.renderLauncher(m.width, h)
}
if m.narrow() {
return m.renderMainNarrow(h)
}
leftW := m.width * 63 / 100
rightW := m.width - leftW
// In-session / approval. The right panel cycles (d): events, changes (token usage +
// changed files), or off (output takes the full width).
if m.rightPanel == 2 {
return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true)
}
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
var right string
if m.rightPanel == 1 {
right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false)
} else {
right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
}
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style
// list of files the session has written (summed +/- from each write's diff). ^x opens the
// full diff. Drives the right panel when rightPanel == 1.
func (m Model) changesRows(w, h int) []string {
t := m.theme
turns, toks := 0, 0
for _, e := range m.routerMessages[m.selectedID] {
if e.Role == "router" && e.Metrics != nil {
turns++
toks += e.Metrics.TotalTokens
}
}
rows := []string{
t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) +
t.span(" · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim),
"",
}
type chg struct{ add, del int }
var order []string
seen := map[string]*chg{}
for _, e := range m.routerMessages[m.selectedID] {
if e.Role != "tool" || !isUnifiedDiff(e.Content) {
continue
}
p, a, d := diffSummary(e.Content)
if seen[p] == nil {
seen[p] = &chg{}
order = append(order, p)
}
seen[p].add += a
seen[p].del += d
}
if len(order) == 0 {
return append(rows, t.span("no file changes yet", t.P.Faint))
}
rows = append(rows, t.span("changes (^x for full diff)", t.P.Faint))
for _, p := range order {
c := seen[p]
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ")
delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) +
t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render(""+itoa(c.del))
rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+" "+delta)
}
return rows
}
// renderLauncher is the idle screen: a compact, opencode-style input centered in the main
// area, with a hideable right rail of status + quick keys. The session list isn't shown —
// it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled.
func (m Model) renderLauncher(w, h int) string {
t := m.theme
railW := 26
if m.railHidden || m.narrow() || w < 66 {
railW = 0
}
leftW := w - railW
inW := leftW * 82 / 100
if inW > 84 {
inW = 84
}
if inW < 24 {
inW = 24
}
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
if railW == 0 {
return left
}
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
}
// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
// box scrolls to keep the cursor's tail in view.
const maxInputLines = 6
// editorLines renders the input buffer as styled display lines with the caret at the cursor,
// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space.
func (m Model) editorLines() []string {
t := m.theme
caret := ""
if m.editMode == ModeInsert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
}
buf := m.inputBuffer
cur := m.inputCursor
if cur > len(buf) {
cur = len(buf)
}
if cur < 0 {
cur = 0
}
style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) }
before := strings.Split(buf[:cur], "\n")
after := strings.Split(buf[cur:], "\n")
var lines []string
for _, ln := range before[:len(before)-1] {
lines = append(lines, style(ln))
}
lines = append(lines, style(before[len(before)-1])+caret+style(after[0]))
for _, ln := range after[1:] {
lines = append(lines, style(ln))
}
if len(lines) > maxInputLines {
lines = lines[len(lines)-maxInputLines:]
}
return lines
}
// launcherInput renders the compact (growing) input box plus a status sub-line
// (<workflow> · <model> left, action hints right).
func (m Model) launcherInput(w int) string {
t := m.theme
insert := m.editMode == ModeInsert
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(" ")
indent := t.span(" ", t.P.Bg)
var content []string
if m.inputBuffer == "" && !insert {
content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
} else {
for i, ln := range m.editorLines() {
if i == 0 {
content = append(content, prompt+ln)
} else {
content = append(content, indent+ln)
}
}
}
box := t.box("", content, w, len(content)+2, insert)
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint)
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
}
// launcherRail is the idle status + quick-keys panel on the right.
func (m Model) launcherRail(w int) string {
t := m.theme
var rows []string
if m.connected {
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected"))
} else {
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline"))
}
count := t.span(plural(len(m.sessions), "session"), t.P.Dim)
if a := m.activeSessionCount(); a > 0 {
count += t.span(" · "+itoa(a)+" active", t.P.Faint)
}
key := func(k, d string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim)
}
rows = append(rows, count, "",
key("i", "type"),
key("Tab", "workflow"),
key("R", "resume"),
key("?", "keys"),
key("p", "commands"),
)
return t.box("correx", rows, w, len(rows)+2, false)
}
// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled.
func (m Model) launcherWfName() string {
if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) {
return "chat"
}
return m.workflows[m.launcherWf-1].ID
}
// activeSessionCount is the number of sessions not in a terminal (completed/failed) state.
func (m Model) activeSessionCount() int {
n := 0
for _, s := range m.sessions {
u := strings.ToUpper(s.Status)
if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") {
n++
}
}
return n
}
// renderMainNarrow gives the main area a single full-width column when the terminal
// is too slim for side-by-side panels: the session list when idle, and the output
// transcript stacked over the live event strip when in-session (so both still fit
// — the strip is too valuable to drop, the constraint is width not height).
func (m Model) renderMainNarrow(h int) string {
w := m.width
// Idle is handled by renderLauncher (which drops the rail when narrow); this only
// runs for in-session / approval: stack output over events.
outH := h * 55 / 100
if outH < 3 {
outH = 3
}
evH := h - outH
if evH < 3 {
evH = 3
outH = h - evH
}
out := m.theme.box("output", m.routerRows(w-4, outH-2), w, outH, true)
ev := m.theme.box("events", m.eventRows(w-4, evH), w, evH, false)
return lipgloss.JoinVertical(lipgloss.Left, out, ev)
}
// --- input bar ---
// flattenForDisplay collapses control characters (newlines from a multi-line paste, tabs)
// to a single visible line so the fixed-height input box can't overflow and smear stale cells
// (F-006). The underlying inputBuffer keeps its real characters for submission.
func flattenForDisplay(s string) string {
repl := strings.NewReplacer("\r\n", "↵ ", "\r", "↵ ", "\n", "↵ ", "\t", " ")
return repl.Replace(s)
}
func (m Model) renderInput() string {
t := m.theme
placeholder := "Ask anything…"
switch {
case m.inputMode == ModeIntent:
placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…"
case m.inputMode == ModeFilter:
placeholder = "Filter sessions…"
case m.displayState() == StateIdle:
placeholder = "Session name…"
}
insert := m.editMode == ModeInsert
caret := t.span(" ", t.P.Bg)
if insert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
}
var content []string
switch {
case m.inputBuffer == "" && !insert:
content = []string{t.span(placeholder, t.P.Faint)}
case m.inputBuffer == "":
content = []string{caret + t.span(placeholder, t.P.Faint)}
default:
content = m.editorLines()
}
// status sub-line: <session/none> · <uuid> · <mode>
name := "(no session)"
if s := m.session(m.selectedID); s != nil {
name = s.Name
}
mode := "chat"
if m.chatMode == ChatModeSteering {
mode = "steering"
}
if m.inputMode == ModeFilter {
mode = "filter"
}
if m.inputMode == ModeIntent {
mode = "task"
}
sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name)
if m.selectedID != "" {
sub += t.span(" · ", t.P.Faint) +
t.span(m.selectedID, t.P.Faint)
}
sub += t.span(" · ", t.P.Faint) +
t.span(mode, t.P.Dim)
body := append(content, sub)
return t.box("", body, m.width, len(body)+2, insert)
}
// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
// content lines (capped at maxInputLines) + the status sub-line + 2 borders.
func (m Model) inputBarHeight() int {
n := 1
if m.inputBuffer != "" {
n = len(m.editorLines())
}
return n + 1 + 2
}
// --- body row builders ---
// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the
// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years.
// Empty for a zero/absent time (renders as no date rather than a bogus epoch).
func shortDateTime(ms int64) string {
if ms <= 0 {
return ""
}
ts := time.UnixMilli(ms)
if ts.Year() != time.Now().Year() {
return ts.Format("Jan 02 2006")
}
return ts.Format("Jan 02 15:04")
}
func (m Model) routerRows(w, h int) []string {
t := m.theme
if len(m.routerMessages[m.selectedID]) == 0 {
return []string{t.span("awaiting router response…", t.P.Faint)}
}
rows, msgStart := m.buildTranscriptRows(w)
// With a selection, scroll so the selected message is visible (top-aligned, clamped to the
// end); otherwise honour the free-scroll offset (0 = tail-follow the newest output).
if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h {
start := msgStart[m.transcriptSel]
if start > len(rows)-h {
start = len(rows) - h
}
if start < 0 {
start = 0
}
return rows[start : start+h]
}
if len(rows) > h {
off := m.outputScroll
if max := len(rows) - h; off > max {
off = max
}
if off < 0 {
off = 0
}
end := len(rows) - off
return rows[end-h : end]
}
return rows
}
// buildTranscriptRows renders the selected session's transcript to display rows (and records each
// message's first row index for scroll-to-selection). Split out so the scroll handler can measure
// the full height against the same content the renderer windows.
func (m Model) buildTranscriptRows(w int) ([]string, []int) {
t := m.theme
msgs := m.routerMessages[m.selectedID]
var rows []string
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
for mi, e := range msgs {
msgStart[mi] = len(rows)
switch e.Role {
case "user":
if mi == m.transcriptSel {
// Selected message: an inverted tag + a highlighted background bar so it's
// unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy).
tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" ")
rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content))
break
}
tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("")
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
case "router":
// The router turn is model output: render it as markdown (bold,
// lists, headings, code) wrapped to the panel width. glamour applies
// its own inline foreground colors; we keep the opaque panel by
// fixing each line's background. On any failure renderMarkdown hands
// back the plain content, which still flows through this path fine.
rendered := renderMarkdown(e.Content, w)
for _, ln := range strings.Split(rendered, "\n") {
rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
}
if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint))
}
case "tool":
rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim))
case "narration_llm":
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
lines := wrap(e.Content, w-2)
for i, ln := range lines {
if i == 0 {
rows = append(rows, diamond+" "+t.span(ln, t.P.FgStrong))
} else {
rows = append(rows, " "+t.span(ln, t.P.FgStrong))
}
}
if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint))
}
case "narration", "stage":
rows = append(rows, t.span(e.Content, t.P.Dim))
case "action":
// Inline "external feedback" row: a side-effecting action (tool call, write/edit,
// approval, grant) surfaced in the conversation flow. Muted via actionsHidden.
if m.actionsHidden {
break
}
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon)
rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim))
}
}
return rows, msgStart
}
func (m Model) eventRows(w, h int) []string {
t := m.theme
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
t.span(" ", t.P.Bg) + m.eventStreamBadge()
s := m.session(m.selectedID)
if s == nil || len(s.Events) == 0 {
return []string{header, "", t.span("no events yet", t.P.Faint)}
}
evRows := make([]string, 0, len(s.Events))
for _, e := range s.Events {
cat := inferCategory(e.Type)
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
Render(" " + padRaw(cat, 9) + " ")
evRows = append(evRows,
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
}
// Show the latest events that fit under the 2-line header (box inner = h-2).
if avail := h - 4; avail >= 1 && len(evRows) > avail {
evRows = evRows[len(evRows)-avail:]
}
return append([]string{header, ""}, evRows...)
}
// eventStreamBadge reflects whether the *selected session's* stream is still live, not
// just whether the socket is connected: a completed / failed / paused session emits no
// more events, so the old connection-only "● live" went stale the moment a session ended.
func (m Model) eventStreamBadge() string {
t := m.theme
if !m.connected {
return t.span("○ idle", t.P.Faint)
}
if s := m.session(m.selectedID); s != nil {
badge := func(fg color.Color, text string) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
}
switch u := strings.ToUpper(s.Status); {
case strings.Contains(u, "FAIL"):
return badge(t.P.Bad, "✗ failed")
case strings.Contains(u, "COMPLETE"):
return badge(t.P.OK, "✓ done")
case strings.Contains(u, "PAUSE"):
return badge(t.P.Warn, "‖ paused")
}
}
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
}
// --- width helpers ---
// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
func shortPath(p string) string {
if home := os.Getenv("HOME"); home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
// fill left-justifies a styled line and pads with bg to width w.
func (m Model) fill(s string, w int, bg color.Color) string {
return " " + padTo(s, w-1, bg)
}
// justify places left and right segments on a bg-filled line of width w, truncating
// so the line never overflows w. The left segment is shrunk first (the right holds
// the high-signal status — connection clock / active spinner); the right is clamped
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
func (m Model) justify(left, right string, w int, bg color.Color) string {
avail := w - 2 // leading + trailing space
lw := lipgloss.Width(left)
rw := lipgloss.Width(right)
if lw+rw+1 > avail {
maxLeft := avail - rw - 1
if maxLeft < 0 {
maxLeft = 0
}
left = ansi.Truncate(left, maxLeft, "…")
lw = lipgloss.Width(left)
if lw+rw+1 > avail {
right = ansi.Truncate(right, max(0, avail-lw-1), "…")
rw = lipgloss.Width(right)
}
}
mid := avail - lw - rw
if mid < 1 {
mid = 1
}
pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", mid))
return " " + left + pad + right + lipgloss.NewStyle().Background(bg).Render(" ")
}
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque.
func padTo(s string, w int, bg color.Color) string {
vw := lipgloss.Width(s)
if vw == w {
return s
}
if vw > w {
return ansi.Truncate(s, w, "")
}
return s + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", w-vw))
}
// padRaw pads a plain (unstyled) string to width w with spaces, truncating with
// an ellipsis only when it genuinely overflows.
func padRaw(s string, w int) string {
n := len([]rune(s))
if n == w {
return s
}
if n > w {
if w <= 1 {
return string([]rune(s)[:w])
}
return string([]rune(s)[:w-1]) + "…"
}
return s + strings.Repeat(" ", w-n)
}
func statusColor(t Theme, status string) color.Color {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
return t.P.Bad
case strings.Contains(u, "COMPLETE"):
return t.P.Dim
case strings.Contains(u, "PAUSE"), strings.Contains(u, "STARTING"):
return t.P.Warn
case strings.Contains(u, "CHAT"), strings.Contains(u, "ACTIVE"):
return t.P.Accent
default:
return t.P.Dim
}
}
func statusLabel(status string) string {
u := strings.ToUpper(status)
if i := strings.Index(u, " "); i > 0 {
return u[:i]
}
return u
}
func statusGlyph(t Theme, status string) string {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("✗")
case strings.Contains(u, "COMPLETE"):
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("✓")
default:
return t.span("·", t.P.Faint)
}
}
// span renders text with foreground fg over the panel background.
func (t Theme) span(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
}
// box draws a rounded, opaque panel with the title embedded in the top border.
func (t Theme) box(title string, body []string, w, h int, active bool) string {
if w < 4 {
w = 4
}
if h < 2 {
h = 2
}
inner := w - 2
textW := inner - 2
bc := t.P.Border
if active {
bc = t.P.BorderHi
}
bs := lipgloss.NewStyle().Foreground(bc).Background(t.P.Bg)
cell := lipgloss.NewStyle().Background(t.P.Bg)
var top string
if title == "" {
top = bs.Render("╭" + strings.Repeat("─", inner) + "╮")
} else {
ts := t.PanelTitle
if active {
ts = t.TitleHi
}
label := strings.ToUpper(title)
fill := inner - (2 + lipgloss.Width(label) + 1)
if fill < 0 {
fill = 0
}
top = bs.Render("╭─ ") + ts.Render(label) + bs.Render(" "+strings.Repeat("─", fill)) + bs.Render("╮")
}
lines := make([]string, 0, h)
lines = append(lines, top)
for i := 0; i < h-2; i++ {
content := ""
if i < len(body) {
content = body[i]
}
padded := padTo(content, textW, t.P.Bg)
lines = append(lines, bs.Render("│")+cell.Render(" ")+padded+cell.Render(" ")+bs.Render("│"))
}
lines = append(lines, bs.Render("╰"+strings.Repeat("─", inner)+"╯"))
return strings.Join(lines, "\n")
}
func plural(n int, word string) string {
s := itoa(n) + " " + word
if n != 1 {
s += "s"
}
return s
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// toolRowSummary collapses a tool-output transcript entry into a one-line pointer. A
// write/edit entry holds a unified diff, so summarise it by what ^x actually shows — the
// diff's row count — instead of the raw diff byte length (which read as a meaningless
// "N chars": it counted +/-/@@/header bytes, not anything the operator cares about, and
// len() is bytes not characters). Non-diff output falls back to a true character count.
func toolRowSummary(content string) string {
if isUnifiedDiff(content) {
return "· diff (" + itoa(previewRowCount(content)) + " rows) — ^x to view"
}
return "· tool output (" + itoa(len([]rune(content))) + " chars) — ^x to view"
}
// metricsSuffix formats the faint latency+token annotation for a ROUTER turn.
func metricsSuffix(mtr *TurnMetrics) string {
if mtr == nil {
return ""
}
return fmt.Sprintf(" %.1fs · %d tokens", float64(mtr.LatencyMs)/1000.0, mtr.TotalTokens)
}
// wrap splits s into lines no wider than w (rune-based, whitespace-greedy).
func wrap(s string, w int) []string {
if w < 1 {
w = 1
}
words := strings.Fields(s)
if len(words) == 0 {
return []string{""}
}
var lines []string
cur := ""
for _, word := range words {
if cur == "" {
cur = word
} else if len(cur)+1+len(word) <= w {
cur += " " + word
} else {
lines = append(lines, cur)
cur = word
}
}
if cur != "" {
lines = append(lines, cur)
}
return lines
}