5ed8ebd0c5
The reasoning trace was rendered twice: once as its own "thinking" row on inference.completed, and again as a ✼ block on the following tool row, whose Reasoning was copied from Model.lastReasoning. The fallback branch was worse — lastReasoning is current model state, so every tool row with no Reasoning of its own (all non-diff rows, all snapshot-restored rows) got the newest trace stamped on it retroactively. Drop the block, the copy, and the now-dead RouterEntry.Reasoning / Model.lastReasoning; the standalone row already covers tool turns. actionToolText clipped every summary to 48 columns, which cut tool output and — worse — harness coach text: read-before-write rejections, write blocks, gate feedback, exactly the messages that say whether the agent was steered or silently blocked. The action renderer already wraps to panel width, so the clip was the only single-line ceiling; raise it to 4000 chars. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1329 lines
42 KiB
Go
1329 lines
42 KiB
Go
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 }
|
||
|
||
// cursorMarker is a private-use rune the composer drops into the caret cell so
|
||
// View() can locate it in the fully-composited frame and place a real terminal
|
||
// cursor there (see splitCursor). It measures one display column — same as the
|
||
// "▏" glyph it stands in for — so the layout is identical whether it resolves to
|
||
// a live cursor or the static glyph.
|
||
const cursorMarker = ""
|
||
|
||
// View renders the whole frame purely from the model (immediate-mode). In
|
||
// bubbletea v2 the View carries terminal state, so renderRaw() builds the string
|
||
// and View() resolves the caret to a real blinking cursor + sets the window title.
|
||
func (m Model) View() tea.View {
|
||
content, cur := splitCursor(m.fillFrame(m.renderRaw()), " ")
|
||
return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()}
|
||
}
|
||
|
||
// fillFrame guarantees every cell of the width×height frame carries the theme backdrop, so a
|
||
// sub-view that leaves a line short or a row empty shows the theme background rather than the
|
||
// terminal-transparent backdrop bleeding through. Pads each line to full width and appends any
|
||
// missing bottom rows; content stays anchored top-left so caret coordinates are unaffected.
|
||
func (m Model) fillFrame(s string) string {
|
||
if m.width <= 0 || m.height <= 0 {
|
||
return s
|
||
}
|
||
pad := m.theme.Screen
|
||
lines := strings.Split(s, "\n")
|
||
out := make([]string, 0, m.height)
|
||
for i := 0; i < m.height; i++ {
|
||
ln := ""
|
||
if i < len(lines) {
|
||
ln = lines[i]
|
||
}
|
||
if gap := m.width - ansi.StringWidth(ln); gap > 0 {
|
||
ln += pad.Render(strings.Repeat(" ", gap))
|
||
}
|
||
out = append(out, ln)
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// render returns the frame as a plain string with the caret drawn as the static
|
||
// "▏" glyph — for the preview tool and golden tests, which have no live cursor.
|
||
func (m Model) render() string {
|
||
content, _ := splitCursor(m.fillFrame(m.renderRaw()), "▏")
|
||
return content
|
||
}
|
||
|
||
// splitCursor finds the caret marker in a composited frame, replaces it with repl
|
||
// (a space for the live cursor cell, "▏" for static), and returns the cursor's
|
||
// screen position — nil when no marker is present (e.g. vim normal mode), which
|
||
// tells bubbletea to hide the cursor.
|
||
func splitCursor(raw, repl string) (string, *tea.Cursor) {
|
||
idx := strings.Index(raw, cursorMarker)
|
||
if idx < 0 {
|
||
return raw, nil
|
||
}
|
||
pre := raw[:idx]
|
||
y := strings.Count(pre, "\n")
|
||
lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line
|
||
x := ansi.StringWidth(raw[lineStart:idx])
|
||
clean := strings.Replace(raw, cursorMarker, repl, 1)
|
||
cur := tea.NewCursor(x, y)
|
||
cur.Shape = tea.CursorBar
|
||
return clean, cur
|
||
}
|
||
|
||
// windowTitle names the terminal window/tab after the active session, falling
|
||
// back to the model name and then the bare program name.
|
||
func (m Model) windowTitle() string {
|
||
if m.displayState() == StateInSession {
|
||
if s := m.session(m.selectedID); s != nil && s.Name != "" {
|
||
return "correx — " + s.Name
|
||
}
|
||
}
|
||
if m.currentModel != "" {
|
||
return "correx — " + m.currentModel
|
||
}
|
||
return "correx"
|
||
}
|
||
|
||
// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode).
|
||
func (m Model) renderRaw() 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") {
|
||
stageLabel := "⟐ " + s.CurrentStage
|
||
if budget, ok := s.TokenBudgetByStage[s.CurrentStage]; ok && budget > 0 {
|
||
stageLabel += fmt.Sprintf(" (%d/%d)", s.StageTokensUsed, budget)
|
||
}
|
||
parts = append(parts, span(stageLabel, 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.Role == "narration_llm") && 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.
|
||
// Explicit newlines (Ctrl+J / Alt+Enter) break rows; a logical line wider than wrapW soft-wraps
|
||
// onto continuation rows so long input never overflows the box. Tabs are flattened to a space.
|
||
func (m Model) editorLines(wrapW int) []string {
|
||
t := m.theme
|
||
if wrapW < 1 {
|
||
wrapW = 1
|
||
}
|
||
// Only the focused composer (no modal over it) owns the live cursor; an open
|
||
// overlay keeps editMode==Insert but draws its own filter caret instead.
|
||
showCaret := m.editMode == ModeInsert && m.overlay == OverlayNone
|
||
|
||
buf := m.inputBuffer
|
||
cur := m.inputCursor
|
||
if cur > len(buf) {
|
||
cur = len(buf)
|
||
}
|
||
if cur < 0 {
|
||
cur = 0
|
||
}
|
||
curRune := len([]rune(buf[:cur]))
|
||
|
||
// First pass: fold the buffer into plain display rows (rune-count wrap), tracking which
|
||
// row/column the cursor lands on so the caret marker can be slotted back in afterwards.
|
||
runes := []rune(buf)
|
||
var rowsRunes [][]rune
|
||
var row []rune
|
||
caretRow, caretCol := 0, 0
|
||
push := func() { rowsRunes = append(rowsRunes, row); row = nil }
|
||
for i := 0; i <= len(runes); i++ {
|
||
if i == curRune {
|
||
caretRow, caretCol = len(rowsRunes), len(row)
|
||
}
|
||
if i == len(runes) {
|
||
break
|
||
}
|
||
if runes[i] == '\n' {
|
||
push()
|
||
continue
|
||
}
|
||
row = append(row, runes[i])
|
||
if len(row) >= wrapW {
|
||
push()
|
||
}
|
||
}
|
||
push()
|
||
|
||
style := func(s []rune) string { return t.span(strings.ReplaceAll(string(s), "\t", " "), t.P.FgStrong) }
|
||
caret := ""
|
||
if showCaret {
|
||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
|
||
}
|
||
lines := make([]string, len(rowsRunes))
|
||
for i, r := range rowsRunes {
|
||
if showCaret && i == caretRow {
|
||
c := min(caretCol, len(r))
|
||
lines[i] = style(r[:c]) + caret + style(r[c:])
|
||
} else {
|
||
lines[i] = style(r)
|
||
}
|
||
}
|
||
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(w - 2) {
|
||
if i == 0 {
|
||
content = append(content, prompt+ln)
|
||
} else {
|
||
content = append(content, indent+ln)
|
||
}
|
||
}
|
||
}
|
||
// Borderless (see renderInput): top rule + flush text, so a terminal copy is just the prompt.
|
||
ruleColor := t.P.Border
|
||
if insert {
|
||
ruleColor = t.P.BorderHi
|
||
}
|
||
rule := lipgloss.NewStyle().Foreground(ruleColor).Background(t.P.Bg).Render(strings.Repeat("─", w))
|
||
boxLines := append([]string{rule}, content...)
|
||
for i, ln := range boxLines {
|
||
boxLines[i] = padTo(ln, w, t.P.Bg)
|
||
}
|
||
box := strings.Join(boxLines, "\n")
|
||
|
||
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
|
||
leftSub := t.span(" ", t.P.Faint) + 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.overlay == OverlayNone {
|
||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
|
||
}
|
||
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(m.width)
|
||
}
|
||
|
||
// 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)
|
||
|
||
// Borderless composer: a faint top rule for separation, then the text lines flush-left with
|
||
// no side borders and no right-padding. A bordered/filled box copies its `│` chrome and
|
||
// trailing whitespace when terminal-selected; this keeps a drag-copy to just the typed text.
|
||
ruleColor := t.P.Border
|
||
if insert {
|
||
ruleColor = t.P.BorderHi
|
||
}
|
||
rule := lipgloss.NewStyle().Foreground(ruleColor).Background(t.P.Bg).Render(strings.Repeat("─", m.width))
|
||
lines := append([]string{rule}, content...)
|
||
lines = append(lines, sub)
|
||
// Pad (not just truncate) every line to full width with the panel bg so the terminal backdrop
|
||
// can't bleed through the right of the placeholder / status sub-line (matches the frame fill).
|
||
for i, ln := range lines {
|
||
lines[i] = padTo(ln, m.width, t.P.Bg)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
|
||
// top rule + content lines (capped at maxInputLines) + the status sub-line.
|
||
func (m Model) inputBarHeight() int {
|
||
n := 1
|
||
if m.inputBuffer != "" {
|
||
n = len(m.editorLines(m.width))
|
||
}
|
||
return n + 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
|
||
// Prepend the live execution plan card when the session has locked plan stages.
|
||
if planRows := m.planCardLines(); len(planRows) > 0 {
|
||
rows = append(rows, planRows...)
|
||
}
|
||
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(" ", t.P.Bg)+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 *and* emits a reset after each
|
||
// span, so the panel background has to be repainted per segment —
|
||
// paintBG, not Background().Render. On any failure renderMarkdown
|
||
// hands back the plain content, which still flows through fine.
|
||
rendered := renderMarkdown(e.Content, w)
|
||
for _, ln := range strings.Split(rendered, "\n") {
|
||
rows = append(rows, paintBG(ln, t.P.Bg))
|
||
}
|
||
if s := metricsSuffix(e.Metrics); s != "" {
|
||
rows = append(rows, t.span(s, t.P.Faint))
|
||
}
|
||
case "tool":
|
||
// No reasoning block here: the trace already renders as its own "thinking" row
|
||
// (appended on inference.completed), and repeating it on the following tool row
|
||
// showed the same CoT twice per turn.
|
||
gutter := t.span(" ┈", t.P.Faint)
|
||
summary := toolRowSummary(e.Content)
|
||
avail := w - 4
|
||
if avail < 10 {
|
||
avail = 10
|
||
}
|
||
prefixW := lipgloss.Width(gutter) + 1
|
||
contentW := avail - prefixW
|
||
if contentW < 4 {
|
||
contentW = 4
|
||
}
|
||
parts := wrapContent(summary, contentW)
|
||
for i, ln := range parts {
|
||
if i == 0 {
|
||
rows = append(rows, gutter+" "+t.span(ln, t.P.Dim))
|
||
} else {
|
||
indent := t.span(strings.Repeat(" ", prefixW+1), t.P.Bg)
|
||
rows = append(rows, indent+t.span(ln, t.P.Dim))
|
||
}
|
||
}
|
||
case "thinking":
|
||
// Collapsed by default to a single muted line so the reasoning trace doesn't bury
|
||
// the answer; the palette "thinking" toggle reveals the full dimmed block.
|
||
brain := lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render("✼")
|
||
if !m.thinkingShown {
|
||
n := strings.Count(strings.TrimRight(e.Content, "\n"), "\n") + 1
|
||
rows = append(rows, brain+t.span(" thinking ("+plural(n, "line")+") — palette: thinking", t.P.Faint))
|
||
break
|
||
}
|
||
rendered := renderMarkdown(e.Content, w-2)
|
||
lines := strings.Split(rendered, "\n")
|
||
for i, ln := range lines {
|
||
if i == 0 {
|
||
rows = append(rows, brain+t.span(" ", t.P.Bg)+lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render(ln))
|
||
} else {
|
||
rows = append(rows, t.span(" ", t.P.Bg)+lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render(ln))
|
||
}
|
||
}
|
||
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(" ", t.P.Bg)+t.span(ln, t.P.FgStrong))
|
||
} else {
|
||
rows = append(rows, t.span(" ", t.P.Bg)+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
|
||
}
|
||
iconFg := t.P.Accent2
|
||
switch e.Icon {
|
||
case "✓":
|
||
iconFg = t.P.OK
|
||
case "✗":
|
||
iconFg = t.P.Bad
|
||
case "✕":
|
||
iconFg = t.P.Warn
|
||
}
|
||
gutter := t.span(" ┈", t.P.Faint)
|
||
icon := lipgloss.NewStyle().Foreground(iconFg).Background(t.P.Bg).Bold(true).Render(e.Icon)
|
||
prefix := gutter + " " + icon + " "
|
||
avail := w - 4 // box inner padding
|
||
if avail < 10 {
|
||
avail = 10
|
||
}
|
||
contentW := avail - lipgloss.Width(prefix)
|
||
if contentW < 4 {
|
||
contentW = 4
|
||
}
|
||
parts := wrapContent(e.Content, contentW)
|
||
for i, ln := range parts {
|
||
if i == 0 {
|
||
rows = append(rows, prefix+t.span(ln, t.P.FgStrong))
|
||
} else {
|
||
indent := t.span(strings.Repeat(" ", lipgloss.Width(prefix)), t.P.Bg)
|
||
rows = append(rows, indent+t.span(ln, t.P.FgStrong))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return rows, msgStart
|
||
}
|
||
|
||
// planCardLines renders the live execution plan card for the selected session.
|
||
func (m Model) planCardLines() []string {
|
||
t := m.theme
|
||
s := m.session(m.selectedID)
|
||
if s == nil || len(s.PlanStages) == 0 {
|
||
return nil
|
||
}
|
||
goal := strings.TrimSpace(s.PlanGoal)
|
||
var out []string
|
||
if goal != "" {
|
||
out = append(out, t.span(goal, t.P.Faint))
|
||
}
|
||
stageLabel := "stage"
|
||
if len(s.PlanStages) != 1 {
|
||
stageLabel = "stages"
|
||
}
|
||
out = append(out, t.span("Plan \u00b7 "+itoa(len(s.PlanStages))+" "+stageLabel, t.P.Accent))
|
||
idW := 0
|
||
for _, ps := range s.PlanStages {
|
||
if len(ps.ID) > idW {
|
||
idW = len(ps.ID)
|
||
}
|
||
}
|
||
for _, ps := range s.PlanStages {
|
||
var badge, badgeStyle string
|
||
var badgeColor color.Color
|
||
switch ps.Status {
|
||
case PlanPending:
|
||
badge = "\u25cb"
|
||
badgeColor = t.P.Faint
|
||
case PlanRunning:
|
||
badge = "\u25cf"
|
||
badgeColor = t.P.Accent
|
||
case PlanCompleted:
|
||
badge = "\u2713"
|
||
badgeColor = t.P.OK
|
||
case PlanFailed:
|
||
badge = "\u2717"
|
||
badgeColor = t.P.Bad
|
||
}
|
||
badgeStyle = t.span(badge, badgeColor)
|
||
rest := t.span(" "+padRaw(ps.ID, idW), t.P.Fg)
|
||
out = append(out, " "+badgeStyle+rest)
|
||
}
|
||
return out
|
||
}
|
||
|
||
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(" ", t.P.Bg)+
|
||
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 lipgloss.NewStyle().Background(bg).Render(" ") + 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))
|
||
edge := lipgloss.NewStyle().Background(bg).Render(" ")
|
||
return edge + left + pad + right + edge
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// paintBG makes an ALREADY-STYLED string opaque. Wrapping pre-rendered ANSI in a
|
||
// Background() style does not work: an inner "\x1b[0m" (glamour emits one per styled
|
||
// span) resets the background to the *terminal* default, not to the enclosing lipgloss
|
||
// style, so every cell after the first reset goes transparent. Splitting on the reset
|
||
// and re-applying the background to each segment repaints those cells while leaving each
|
||
// segment's own foreground codes intact.
|
||
//
|
||
// Use this instead of Background().Render(s) whenever s may already contain ANSI —
|
||
// markdown, syntax-highlighted, or otherwise pre-composed content.
|
||
func paintBG(s string, bg color.Color) string {
|
||
s = strings.ReplaceAll(s, "\x1b[m", "\x1b[0m") // normalize the short reset form
|
||
st := lipgloss.NewStyle().Background(bg)
|
||
parts := strings.Split(s, "\x1b[0m")
|
||
for i, p := range parts {
|
||
if p != "" {
|
||
parts[i] = st.Render(p)
|
||
}
|
||
}
|
||
return strings.Join(parts, "")
|
||
}
|
||
|
||
// 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 summary. For
|
||
// write/edit entries (unified diffs) it shows the file path and row count; non-diff output
|
||
// shows a character count. The leading badge (▾ diff / ▾ output) tells the kind at a glance.
|
||
func toolRowSummary(content string) string {
|
||
if isUnifiedDiff(content) {
|
||
n := itoa(previewRowCount(content))
|
||
if p := diffTarget(content); p != "" {
|
||
return "▾ diff · " + p + " · " + n + " rows · ^x"
|
||
}
|
||
return "▾ diff · " + n + " rows · ^x"
|
||
}
|
||
return "▾ output · " + itoa(len([]rune(content))) + " chars · ^x"
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// wrapContent splits plain text into lines no wider than w. Words that fit whole
|
||
// are kept together; a word longer than w is character-wrapped at w. Returns at
|
||
// least one line.
|
||
func wrapContent(s string, w int) []string {
|
||
if w < 1 {
|
||
w = 1
|
||
}
|
||
if len(s) <= w {
|
||
return []string{s}
|
||
}
|
||
words := strings.Fields(s)
|
||
if len(words) == 0 {
|
||
return []string{""}
|
||
}
|
||
var lines []string
|
||
cur := ""
|
||
flush := func() {
|
||
if cur != "" {
|
||
lines = append(lines, cur)
|
||
cur = ""
|
||
}
|
||
}
|
||
for _, word := range words {
|
||
// Does the word itself overflow the width? If so, flush current line,
|
||
// then character-wrap the word.
|
||
if len(word) > w {
|
||
flush()
|
||
for len(word) > w {
|
||
lines = append(lines, word[:w])
|
||
word = word[w:]
|
||
}
|
||
if word != "" {
|
||
cur = word
|
||
}
|
||
continue
|
||
}
|
||
if cur == "" {
|
||
cur = word
|
||
} else if len(cur)+1+len(word) <= w {
|
||
cur += " " + word
|
||
} else {
|
||
lines = append(lines, cur)
|
||
cur = word
|
||
}
|
||
}
|
||
flush()
|
||
if len(lines) == 0 {
|
||
return []string{""}
|
||
}
|
||
return lines
|
||
}
|