cde0c33031
Enter still submits; Ctrl+J (always) and Alt+Enter (most terminals) insert a newline at the cursor. True Shift+Enter isn't distinguishable from Enter in bubbletea v1.3.10 (terminals send the same byte, no kitty-protocol parsing), so these are the working stand-ins — noted in the input hint. - editorLines() renders the buffer as styled rows with the caret at the cursor, splitting on \n and capping at maxInputLines (last rows kept). Both the idle launcher input and the in-session bottom bar use it and grow to fit; View() sizes the bottom bar via inputBarHeight() instead of the fixed inputH. - Footer gains a "^J newline" hint in insert mode (non-filter); the launcher sub-line shows "⌥↵/^J newline". New "compose" preview kind + editor_lines_test (split + height cap). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
935 lines
30 KiB
Go
935 lines
30 KiB
Go
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(), 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 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")}
|
||
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("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)
|
||
}
|
||
|
||
// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
|
||
// box scrolls to keep the cursor's tail in view.
|
||
const maxInputLines = 6
|
||
|
||
// editorLines renders the input buffer as styled display lines with the caret at the cursor,
|
||
// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space.
|
||
func (m Model) editorLines() []string {
|
||
t := m.theme
|
||
caret := ""
|
||
if m.editMode == ModeInsert && m.caretVisible() {
|
||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||
}
|
||
buf := m.inputBuffer
|
||
cur := m.inputCursor
|
||
if cur > len(buf) {
|
||
cur = len(buf)
|
||
}
|
||
if cur < 0 {
|
||
cur = 0
|
||
}
|
||
style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) }
|
||
before := strings.Split(buf[:cur], "\n")
|
||
after := strings.Split(buf[cur:], "\n")
|
||
var lines []string
|
||
for _, ln := range before[:len(before)-1] {
|
||
lines = append(lines, style(ln))
|
||
}
|
||
lines = append(lines, style(before[len(before)-1])+caret+style(after[0]))
|
||
for _, ln := range after[1:] {
|
||
lines = append(lines, style(ln))
|
||
}
|
||
if len(lines) > maxInputLines {
|
||
lines = lines[len(lines)-maxInputLines:]
|
||
}
|
||
return lines
|
||
}
|
||
|
||
// launcherInput renders the compact (growing) input box plus a status sub-line
|
||
// (<workflow> · <model> left, action hints right).
|
||
func (m Model) launcherInput(w int) string {
|
||
t := m.theme
|
||
insert := m.editMode == ModeInsert
|
||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ")
|
||
indent := t.span(" ", t.P.Bg)
|
||
var content []string
|
||
if m.inputBuffer == "" && !insert {
|
||
content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
|
||
} else {
|
||
for i, ln := range m.editorLines() {
|
||
if i == 0 {
|
||
content = append(content, prompt+ln)
|
||
} else {
|
||
content = append(content, indent+ln)
|
||
}
|
||
}
|
||
}
|
||
box := t.box("", content, w, len(content)+2, insert)
|
||
|
||
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
|
||
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
|
||
hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint)
|
||
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
|
||
}
|
||
|
||
// launcherRail is the idle status + quick-keys panel on the right.
|
||
func (m Model) launcherRail(w int) string {
|
||
t := m.theme
|
||
var rows []string
|
||
if m.connected {
|
||
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected"))
|
||
} else {
|
||
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline"))
|
||
}
|
||
count := t.span(plural(len(m.sessions), "session"), t.P.Dim)
|
||
if a := m.activeSessionCount(); a > 0 {
|
||
count += t.span(" · "+itoa(a)+" active", t.P.Faint)
|
||
}
|
||
key := func(k, d string) string {
|
||
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim)
|
||
}
|
||
rows = append(rows, count, "",
|
||
key("i", "type"),
|
||
key("Tab", "workflow"),
|
||
key("R", "resume"),
|
||
key("?", "keys"),
|
||
key("p", "commands"),
|
||
)
|
||
return t.box("correx", rows, w, len(rows)+2, false)
|
||
}
|
||
|
||
// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled.
|
||
func (m Model) launcherWfName() string {
|
||
if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) {
|
||
return "chat"
|
||
}
|
||
return m.workflows[m.launcherWf-1].ID
|
||
}
|
||
|
||
// activeSessionCount is the number of sessions not in a terminal (completed/failed) state.
|
||
func (m Model) activeSessionCount() int {
|
||
n := 0
|
||
for _, s := range m.sessions {
|
||
u := strings.ToUpper(s.Status)
|
||
if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// renderMainNarrow gives the main area a single full-width column when the terminal
|
||
// is too slim for side-by-side panels: the session list when idle, and the output
|
||
// transcript stacked over the live event strip when in-session (so both still fit
|
||
// — the strip is too valuable to drop, the constraint is width not height).
|
||
func (m Model) renderMainNarrow(h int) string {
|
||
w := m.width
|
||
// Idle is handled by renderLauncher (which drops the rail when narrow); this only
|
||
// runs for in-session / approval: stack output over events.
|
||
outH := h * 55 / 100
|
||
if outH < 3 {
|
||
outH = 3
|
||
}
|
||
evH := h - outH
|
||
if evH < 3 {
|
||
evH = 3
|
||
outH = h - evH
|
||
}
|
||
out := m.theme.box("output", m.routerRows(w-4, outH-2), w, outH, true)
|
||
ev := m.theme.box("events", m.eventRows(w-4, evH), w, evH, false)
|
||
return lipgloss.JoinVertical(lipgloss.Left, out, ev)
|
||
}
|
||
|
||
// --- input bar ---
|
||
|
||
// flattenForDisplay collapses control characters (newlines from a multi-line paste, tabs)
|
||
// to a single visible line so the fixed-height input box can't overflow and smear stale cells
|
||
// (F-006). The underlying inputBuffer keeps its real characters for submission.
|
||
func flattenForDisplay(s string) string {
|
||
repl := strings.NewReplacer("\r\n", "↵ ", "\r", "↵ ", "\n", "↵ ", "\t", " ")
|
||
return repl.Replace(s)
|
||
}
|
||
|
||
func (m Model) renderInput() string {
|
||
t := m.theme
|
||
|
||
placeholder := "Ask anything…"
|
||
switch {
|
||
case m.inputMode == ModeIntent:
|
||
placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…"
|
||
case m.inputMode == ModeFilter:
|
||
placeholder = "Filter sessions…"
|
||
case m.displayState() == StateIdle:
|
||
placeholder = "Session name…"
|
||
}
|
||
|
||
insert := m.editMode == ModeInsert
|
||
caret := t.span(" ", t.P.Bg)
|
||
if insert && m.caretVisible() {
|
||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||
}
|
||
var content []string
|
||
switch {
|
||
case m.inputBuffer == "" && !insert:
|
||
content = []string{t.span(placeholder, t.P.Faint)}
|
||
case m.inputBuffer == "":
|
||
content = []string{caret + t.span(placeholder, t.P.Faint)}
|
||
default:
|
||
content = m.editorLines()
|
||
}
|
||
|
||
// status sub-line: <session/none> · <uuid> · <mode>
|
||
name := "(no session)"
|
||
if s := m.session(m.selectedID); s != nil {
|
||
name = s.Name
|
||
}
|
||
mode := "chat"
|
||
if m.chatMode == ChatModeSteering {
|
||
mode = "steering"
|
||
}
|
||
if m.inputMode == ModeFilter {
|
||
mode = "filter"
|
||
}
|
||
if m.inputMode == ModeIntent {
|
||
mode = "task"
|
||
}
|
||
sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name)
|
||
if m.selectedID != "" {
|
||
sub += t.span(" · ", t.P.Faint) +
|
||
t.span(m.selectedID, t.P.Faint)
|
||
}
|
||
sub += t.span(" · ", t.P.Faint) +
|
||
t.span(mode, t.P.Dim)
|
||
|
||
body := append(content, sub)
|
||
return t.box("", body, m.width, len(body)+2, insert)
|
||
}
|
||
|
||
// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
|
||
// content lines (capped at maxInputLines) + the status sub-line + 2 borders.
|
||
func (m Model) inputBarHeight() int {
|
||
n := 1
|
||
if m.inputBuffer != "" {
|
||
n = len(m.editorLines())
|
||
}
|
||
return n + 1 + 2
|
||
}
|
||
|
||
// --- body row builders ---
|
||
|
||
// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the
|
||
// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years.
|
||
// Empty for a zero/absent time (renders as no date rather than a bogus epoch).
|
||
func shortDateTime(ms int64) string {
|
||
if ms <= 0 {
|
||
return ""
|
||
}
|
||
ts := time.UnixMilli(ms)
|
||
if ts.Year() != time.Now().Year() {
|
||
return ts.Format("Jan 02 2006")
|
||
}
|
||
return ts.Format("Jan 02 15:04")
|
||
}
|
||
|
||
func (m Model) routerRows(w, h int) []string {
|
||
t := m.theme
|
||
if len(m.routerMessages[m.selectedID]) == 0 {
|
||
return []string{t.span("awaiting router response…", t.P.Faint)}
|
||
}
|
||
rows, msgStart := m.buildTranscriptRows(w)
|
||
// With a selection, scroll so the selected message is visible (top-aligned, clamped to the
|
||
// end); otherwise honour the free-scroll offset (0 = tail-follow the newest output).
|
||
if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h {
|
||
start := msgStart[m.transcriptSel]
|
||
if start > len(rows)-h {
|
||
start = len(rows) - h
|
||
}
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
return rows[start : start+h]
|
||
}
|
||
if len(rows) > h {
|
||
off := m.outputScroll
|
||
if max := len(rows) - h; off > max {
|
||
off = max
|
||
}
|
||
if off < 0 {
|
||
off = 0
|
||
}
|
||
end := len(rows) - off
|
||
return rows[end-h : end]
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// buildTranscriptRows renders the selected session's transcript to display rows (and records each
|
||
// message's first row index for scroll-to-selection). Split out so the scroll handler can measure
|
||
// the full height against the same content the renderer windows.
|
||
func (m Model) buildTranscriptRows(w int) ([]string, []int) {
|
||
t := m.theme
|
||
msgs := m.routerMessages[m.selectedID]
|
||
var rows []string
|
||
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
|
||
for mi, e := range msgs {
|
||
msgStart[mi] = len(rows)
|
||
switch e.Role {
|
||
case "user":
|
||
if mi == m.transcriptSel {
|
||
// Selected message: an inverted tag + a highlighted background bar so it's
|
||
// unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy).
|
||
tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" › ")
|
||
rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content))
|
||
break
|
||
}
|
||
tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›")
|
||
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
|
||
case "router":
|
||
// The router turn is model output: render it as markdown (bold,
|
||
// lists, headings, code) wrapped to the panel width. glamour applies
|
||
// its own inline foreground colors; we keep the opaque panel by
|
||
// fixing each line's background. On any failure renderMarkdown hands
|
||
// back the plain content, which still flows through this path fine.
|
||
rendered := renderMarkdown(e.Content, w)
|
||
for _, ln := range strings.Split(rendered, "\n") {
|
||
rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
|
||
}
|
||
if s := metricsSuffix(e.Metrics); s != "" {
|
||
rows = append(rows, t.span(s, t.P.Faint))
|
||
}
|
||
case "tool":
|
||
rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim))
|
||
case "narration_llm":
|
||
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
|
||
lines := wrap(e.Content, w-2)
|
||
for i, ln := range lines {
|
||
if i == 0 {
|
||
rows = append(rows, diamond+" "+t.span(ln, t.P.FgStrong))
|
||
} else {
|
||
rows = append(rows, " "+t.span(ln, t.P.FgStrong))
|
||
}
|
||
}
|
||
if s := metricsSuffix(e.Metrics); s != "" {
|
||
rows = append(rows, t.span(s, t.P.Faint))
|
||
}
|
||
case "narration", "stage":
|
||
rows = append(rows, t.span(e.Content, t.P.Dim))
|
||
case "action":
|
||
// Inline "external feedback" row: a side-effecting action (tool call, write/edit,
|
||
// approval, grant) surfaced in the conversation flow. Muted via actionsHidden.
|
||
if m.actionsHidden {
|
||
break
|
||
}
|
||
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon)
|
||
rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim))
|
||
}
|
||
}
|
||
return rows, msgStart
|
||
}
|
||
|
||
func (m Model) eventRows(w, h int) []string {
|
||
t := m.theme
|
||
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
|
||
t.span(" ", t.P.Bg) + m.eventStreamBadge()
|
||
|
||
s := m.session(m.selectedID)
|
||
if s == nil || len(s.Events) == 0 {
|
||
return []string{header, "", t.span("no events yet", t.P.Faint)}
|
||
}
|
||
evRows := make([]string, 0, len(s.Events))
|
||
for _, e := range s.Events {
|
||
cat := inferCategory(e.Type)
|
||
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
|
||
Render(" " + padRaw(cat, 9) + " ")
|
||
evRows = append(evRows,
|
||
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
|
||
t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
|
||
}
|
||
// Show the latest events that fit under the 2-line header (box inner = h-2).
|
||
if avail := h - 4; avail >= 1 && len(evRows) > avail {
|
||
evRows = evRows[len(evRows)-avail:]
|
||
}
|
||
return append([]string{header, ""}, evRows...)
|
||
}
|
||
|
||
// eventStreamBadge reflects whether the *selected session's* stream is still live, not
|
||
// just whether the socket is connected: a completed / failed / paused session emits no
|
||
// more events, so the old connection-only "● live" went stale the moment a session ended.
|
||
func (m Model) eventStreamBadge() string {
|
||
t := m.theme
|
||
if !m.connected {
|
||
return t.span("○ idle", t.P.Faint)
|
||
}
|
||
if s := m.session(m.selectedID); s != nil {
|
||
badge := func(fg 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
|
||
}
|