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.renderRaw(), " ") return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()} } // 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.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") { 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 · , 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 := "" // 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. if m.editMode == ModeInsert && m.overlay == OverlayNone { caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker) } 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 // ( · 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.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() } // status sub-line: · · 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 }