Files
correx/apps/tui-go/internal/app/view.go
T
kami 9d00612742 feat(tui-go): idle launcher — centered input + status/keys rail (Phase 1)
Replace the idle two-panel layout (session list + welcome, where the welcome
panel duplicated the list) with an opencode-style launcher: a compact, centered
input whose lower-right shows the launch target and model (chat by default), a
hideable right rail of status + quick keys, and no session list — it's a keypress
away via R.

- Tab (or w) cycles the launch target: chat → workflows → chat (launcherWf),
  shown at the input. Enter on chat starts a chat; on a workflow, StartSession
  with the typed text as the brief.
- Right rail (connection, session/active counts, i/Tab/R/?/p keys) hides via the
  new "rail" palette command. Drops automatically on narrow terminals.
- View() suppresses the bottom input bar on idle (the input is the launcher);
  footer + ? help updated to the launcher keys (help-coverage test extended).

The old idle-panel renderers (sessionRows/welcomeRows/workflowRows) are now
unused but kept in the tree pending Phase 2 + a decision on where the session
dates should live now that the idle list is gone. Multi-line input (Ctrl+J /
Alt+Enter newline) is Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:33:19 +00:00

990 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"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"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).
func (m Model) View() 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(), inputH
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 lipgloss.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")}
// 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("t", "tools"), 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: output transcript over the event stream.
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
right := m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
// 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.WithWhitespaceBackground(t.P.Bg))
if railW == 0 {
return left
}
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
lipgloss.WithWhitespaceBackground(t.P.Bg))
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
}
// launcherInput renders the compact 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(" ")
caret := t.span(" ", t.P.Bg)
if insert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
}
var line string
switch {
case m.inputBuffer == "" && !insert:
line = prompt + t.span("Ask anything…", t.P.Faint)
case m.inputBuffer == "":
line = prompt + caret + t.span("Ask anything…", t.P.Faint)
default:
line = prompt + t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
}
box := t.box("", []string{line}, w, 3, 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 workflow · 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
if m.displayState() == StateIdle {
title := "sessions"
body := m.sessionRows(w - 4)
if m.wfVisible {
title, body = "workflows", m.workflowRows(w-4)
}
return m.theme.box(title, body, w, h, true)
}
// 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 line string
switch {
case m.inputBuffer == "" && !insert:
line = t.span(placeholder, t.P.Faint)
case m.inputBuffer == "":
line = caret + t.span(placeholder, t.P.Faint)
default:
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
}
// 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 := []string{line, sub}
return t.box("", body, m.width, inputH, insert)
}
// --- body row builders ---
func (m Model) sessionRows(w int) []string {
t := m.theme
list := m.filteredSessions()
if len(list) == 0 {
return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)}
}
rows := make([]string, 0, len(list))
for _, s := range list {
sel := s.ID == m.selectedID && !m.wfVisible
short := s.ID
if len(short) > 6 {
short = short[:6]
}
nameFg := t.P.Fg
bar := t.span(" ", t.P.Bg)
if sel {
bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ")
nameFg = t.P.FgStrong
}
idCell := t.span("["+short+"] ", t.P.Faint)
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true).
Render(padRaw(statusLabel(s.Status), 9))
stageStr := ""
if s.CurrentStage != "" {
stageStr = " [" + s.CurrentStage + "]"
}
dateCell := t.span(shortDateTime(s.LastEventAt), t.P.Faint)
// Right-align the last-activity date; truncate the name first so the date column
// always survives the box's width clamp (padTo) even on a slim panel.
fixed := lipgloss.Width(bar) + lipgloss.Width(idCell) + lipgloss.Width(statusCell) + 1 + len([]rune(stageStr))
name := s.Name
if budget := w - fixed - lipgloss.Width(dateCell) - 1; budget >= 1 && len([]rune(name)) > budget {
name = truncate(name, budget)
}
nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + name)
stageCell := t.span(stageStr, t.P.Dim)
left := bar + idCell + statusCell + nameCell + stageCell
gap := w - lipgloss.Width(left) - lipgloss.Width(dateCell)
if gap < 1 {
gap = 1
}
rows = append(rows, left+t.span(strings.Repeat(" ", gap), t.P.Bg)+dateCell)
}
return rows
}
// 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) workflowRows(w int) []string {
t := m.theme
if len(m.workflows) == 0 {
return []string{t.span("no workflows advertised", t.P.Faint)}
}
rows := make([]string, 0, len(m.workflows))
for i, wf := range m.workflows {
marker := t.span(" ", t.P.Bg)
fg := t.P.Fg
if i == m.wfIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ")
fg = t.P.FgStrong
}
rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint))
}
return rows
}
func (m Model) welcomeRows(w int) []string {
t := m.theme
title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness")
var status string
if m.connected {
status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") +
t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim)
} else {
status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected")
}
rows := []string{title, status, "",
t.span("Select a session from the left", t.P.Dim),
t.span("or type a name to start a new one.", t.P.Dim), ""}
if n := len(m.sessions); n > 0 {
rows = append(rows, t.span("recent:", t.P.Faint))
start := 0
if n > 5 {
start = n - 5
}
for _, s := range m.sessions[start:] {
mark := statusGlyph(t, s.Status)
rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(shortDateTime(s.LastEventAt), t.P.Faint)+" "+mark)
}
}
return rows
}
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 lipgloss.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 lipgloss.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 lipgloss.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 lipgloss.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) lipgloss.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 lipgloss.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
}