From 079c92eda20db4d21b579b2c2cb9624f2f9aead7 Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Mon, 7 Sep 2026 17:12:41 -0400 Subject: [PATCH] new TUI --- internal/tui/approval_test.go | 23 +- internal/tui/render.go | 476 ++++++++++++++++++++++++++++++++++ internal/tui/render_test.go | 325 +++++++++++++++++++++++ internal/tui/theme.go | 99 +++++++ internal/tui/theme_test.go | 46 ++++ internal/tui/tui.go | 307 +++++++++++----------- 6 files changed, 1117 insertions(+), 159 deletions(-) create mode 100644 internal/tui/render.go create mode 100644 internal/tui/render_test.go create mode 100644 internal/tui/theme.go create mode 100644 internal/tui/theme_test.go diff --git a/internal/tui/approval_test.go b/internal/tui/approval_test.go index 52fda31..920523e 100644 --- a/internal/tui/approval_test.go +++ b/internal/tui/approval_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "regexp" "strconv" "strings" "testing" @@ -27,8 +28,11 @@ func TestApprovalDefaultsToDenyAndShowsExactRequest(t *testing.T) { t.Fatalf("mode=%v allow=%v", m.mode, m.approvalAllow) } view := m.View().Content - for _, want := range []string{strconv.Quote(command), `guest workdir: "src/path with space"`, "timeout: 37s", "[Deny]"} { - if !strings.Contains(view, want) { + // The dialog wraps to the panel width, so compare against a flattened + // rendering: the exact quoted request must still be recoverable. + flat := flattenApproval(view) + for _, want := range []string{strconv.Quote(command), strconv.Quote("src/path with space"), "37s", "[Deny]"} { + if !strings.Contains(flat, flattenApproval(want)) { t.Fatalf("approval view missing %q:\n%s", want, view) } } @@ -152,3 +156,18 @@ func approvalRequest(ctx context.Context, params protocol.RunCommandApprovalPara settled: make(chan struct{}), } } + +var ansiPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// flattenApproval strips styling, panel chrome, and layout whitespace so an +// assertion can read the dialog's content across wrapped lines. +func flattenApproval(view string) string { + return strings.Map(func(r rune) rune { + switch r { + case '\u250f', '\u2513', '\u2517', '\u251b', '\u2503', '\u2502', '\u2501', '\u2500', + '\u2521', '\u2529', '\u2514', '\u2518', ' ', '\n', '\t': + return -1 + } + return r + }, ansiPattern.ReplaceAllString(view, "")) +} diff --git a/internal/tui/render.go b/internal/tui/render.go new file mode 100644 index 0000000..519ed71 --- /dev/null +++ b/internal/tui/render.go @@ -0,0 +1,476 @@ +package tui + +import ( + "image/color" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/AdminTurnedDevOps/ABox/protocol" +) + +// entryKind tags a transcript line so the renderer can style it without +// storing ANSI in the log. Log lines stay plain: they are width-wrapped and +// persisted to transcript.json, and escape sequences would break both. +type entryKind int + +const ( + entryAssistant entryKind = iota + entryUser + entryTool + entryError + entryNotice + entryBlank +) + +type entry struct { + kind entryKind + text string +} + +const ( + userPrefix = "you: " + toolPrefix = " ▸ " + errorPrefix = "error: " +) + +// noticePrefixes are host-side status lines the model never produces. +var noticePrefixes = []string{"commands:", "connected ", "credential ", "mcp "} + +func entriesFromLog(lines []string) []entry { + out := make([]entry, 0, len(lines)) + for _, line := range lines { + out = append(out, classifyLine(line)) + } + return out +} + +func classifyLine(line string) entry { + switch { + case line == "": + return entry{kind: entryBlank} + case strings.HasPrefix(line, userPrefix): + return entry{kind: entryUser, text: strings.TrimPrefix(line, userPrefix)} + case strings.HasPrefix(line, toolPrefix): + return entry{kind: entryTool, text: strings.TrimPrefix(line, toolPrefix)} + case strings.HasPrefix(line, errorPrefix): + return entry{kind: entryError, text: strings.TrimPrefix(line, errorPrefix)} + case isNotice(line): + return entry{kind: entryNotice, text: line} + default: + return entry{kind: entryAssistant, text: line} + } +} + +func isNotice(line string) bool { + for _, p := range noticePrefixes { + if strings.HasPrefix(line, p) { + return true + } + } + return false +} + +func logFromEntries(entries []entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.plain()) + } + return out +} + +func (e entry) plain() string { + switch e.kind { + case entryBlank: + return "" + case entryUser: + return userPrefix + e.text + case entryTool: + return toolPrefix + e.text + case entryError: + return errorPrefix + e.text + default: + return e.text + } +} + +// headerData is the status rail's content, already reduced to display strings. +type headerData struct { + model string + vm string + net string + key string +} + +const ( + minPanelWidth = 24 + headerTitle = "ABox" +) + +// renderHeader draws the instrument rail: a titled top rule, two rows of +// aligned label/value pairs, and a seam that joins the transcript panel below. +func renderHeader(t theme, h headerData, width int) string { + if width < minPanelWidth { + width = minPanelWidth + } + inner := width - 4 // two border cells plus one pad cell each side + + title := t.style(t.accent).Render(headerTitle) + titleRun := lipgloss.Width(headerTitle) + 3 // "┏━ " prefix + fill := width - titleRun - 2 // trailing space and "┓" + if fill < 0 { + fill = 0 + } + rule := t.style(t.line) + top := rule.Render("┏━ ") + title + rule.Render(" "+strings.Repeat("━", fill)+"┓") + seam := rule.Render("┡" + strings.Repeat("━", width-2) + "┩") + + half := inner / 2 + rows := []string{ + fieldPair(t, "MODEL", h.model, half, "VM", h.vm, inner-half), + fieldPair(t, "NET", h.net, half, "KEY", h.key, inner-half), + } + out := []string{top} + for _, r := range rows { + out = append(out, rule.Render("┃ ")+r+rule.Render(" ┃")) + } + return strings.Join(append(out, seam), "\n") +} + +// Label columns are fixed so values line up down the rail. +const ( + leftLabelW = 6 + rightLabelW = 4 +) + +// fieldPair lays out two label/value cells on one rail row. Only the right +// column carries a status dot: MODEL and NET are settings, not health. +func fieldPair(t theme, leftLabel, leftVal string, leftW int, rightLabel, rightVal string, rightW int) string { + return field(t, leftLabel, leftVal, leftLabelW, leftW, false) + + field(t, rightLabel, rightVal, rightLabelW, rightW, true) +} + +func field(t theme, label, value string, labelW, w int, dotted bool) string { + cell := padTo(t.style(t.dim).Render(label), labelW) + body := value + if dotted { + body = t.style(t.statusTone(value)).Render(statusSymbol(value)) + " " + value + } + return padTo(cell+t.style(t.text).Render(body), w) +} + +// padTo pads or truncates to an exact cell width, measuring visible width so +// styled text lines up the same as plain text. +func padTo(s string, w int) string { + if w <= 0 { + return "" + } + if got := lipgloss.Width(s); got < w { + return s + strings.Repeat(" ", w-got) + } else if got > w { + return truncTo(s, w) + } + return s +} + +func truncTo(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) <= w { + return s + } + runes := []rune(s) + var b strings.Builder + used, styled := 0, false + for i := 0; i < len(runes); i++ { + if runes[i] == '\x1b' { // copy escape sequences whole; they cost no cells + j := i + for j < len(runes) && runes[j] != 'm' { + j++ + } + if j < len(runes) { + j++ + } + seq := string(runes[i:j]) + b.WriteString(seq) + styled = seq != "\x1b[m" + i = j - 1 + continue + } + rw := lipgloss.Width(string(runes[i])) + if used+rw > w { + break + } + used += rw + b.WriteRune(runes[i]) + } + if styled { // never leave the terminal in a style the cut discarded + b.WriteString("\x1b[m") + } + return b.String() +} + +const gutterBar = "▎" + +// wrapCells wraps to a visible-cell budget, breaking on word boundaries and +// never splitting a rune. The previous byte-sliced wrap corrupted multibyte +// text at the width bound. +func wrapCells(s string, width int) []string { + if width < 4 { + width = 4 + } + s = strings.ReplaceAll(s, "\t", " ") + var out []string + for _, para := range strings.Split(s, "\n") { + out = append(out, wrapParagraph(para, width)...) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func wrapParagraph(para string, width int) []string { + if para == "" { + return []string{""} + } + var lines []string + runes := []rune(para) + for len(runes) > 0 { + if lipgloss.Width(string(runes)) <= width { + lines = append(lines, string(runes)) + break + } + cut, used := 0, 0 + for i, r := range runes { + rw := lipgloss.Width(string(r)) + if used+rw > width { + break + } + used += rw + cut = i + 1 + } + if cut == 0 { + cut = 1 // a single rune wider than the budget still has to advance + } + if sp := lastSpace(runes[:cut]); sp > width/4 { + cut = sp + } + lines = append(lines, strings.TrimRight(string(runes[:cut]), " ")) + runes = []rune(strings.TrimLeft(string(runes[cut:]), " ")) + } + return lines +} + +func lastSpace(runes []rune) int { + for i := len(runes) - 1; i >= 0; i-- { + if runes[i] == ' ' { + return i + } + } + return -1 +} + +// gutter returns the speaker bar, its label, and the tone for the body text. +func (t theme) gutter(kind entryKind) (label string, tone color.Color, inline bool) { + switch kind { + case entryUser: + return "you", t.accent, false + case entryTool: + return "tool", t.tool, true + case entryError: + return "error", t.danger, true + case entryNotice: + return "", t.dim, true + default: + return "abox", t.text, false + } +} + +// renderTranscript lays out the conversation: prose speakers get a labeled +// gutter with an indented body, short machine lines stay inline. +func renderTranscript(t theme, entries []entry, width, height int) string { + var lines []string + for _, e := range entries { + lines = append(lines, renderEntry(t, e, width)...) + } + return strings.Join(tail(lines, height), "\n") +} + +func renderEntry(t theme, e entry, width int) []string { + if e.kind == entryBlank { + return []string{""} + } + label, tone, inline := t.gutter(e.kind) + if label == "" { // notices carry no speaker + var out []string + for _, l := range wrapCells(e.text, width) { + out = append(out, t.style(tone).Render(l)) + } + return out + } + head := t.style(tone).Render(gutterBar + label) + if inline { + wrapped := wrapCells(e.text, width-lipgloss.Width(gutterBar+label)-2) + out := []string{head + t.style(t.text).Render(" "+wrapped[0])} + for _, l := range wrapped[1:] { + out = append(out, t.style(t.dim).Render(" "+l)) + } + return out + } + out := []string{head} + for _, l := range wrapCells(e.text, width-1) { + out = append(out, t.style(t.text).Render(" "+l)) + } + return out +} + +// renderPanel draws a titled heavy-border box at an exact width. tone colors +// the border and title so a panel can signal severity (danger for approvals). +func renderPanel(t theme, title string, tone color.Color, body []string, width int) string { + if width < minPanelWidth { + width = minPanelWidth + } + inner := width - 4 + rule := t.style(tone) + + fill := width - lipgloss.Width(title) - 5 // "┏━ ", " ", "┓" + if fill < 0 { + fill = 0 + } + out := []string{rule.Render("┏━ ") + t.title(tone).Render(title) + rule.Render(" "+strings.Repeat("━", fill)+"┓")} + for _, line := range body { + out = append(out, rule.Render("┃ ")+padTo(line, inner)+rule.Render(" ┃")) + } + return strings.Join(append(out, rule.Render("┗"+strings.Repeat("━", width-2)+"┛")), "\n") +} + +// renderApproval is the run_command consent prompt. It shows exactly what will +// execute, where, and for how long, because this is the last gate before +// model-authored shell runs in the guest. +func renderApproval(t theme, p protocol.RunCommandApprovalParams, allow bool, width int) string { + workdir := p.WorkDir + if workdir == "" { + workdir = protocol.GuestRepoDir + } + inner := width - 4 + + label := func(k, v string) string { + return t.style(t.dim).Render(padTo(k, 9)) + t.style(t.text).Render(v) + } + body := []string{ + t.style(t.tool).Render("run_command"), + "", + } + // Quoted: a command carrying newlines or box-drawing characters could + // otherwise paint convincing fake rows inside this dialog. + for _, line := range wrapCells(strconv.Quote(p.Command), inner-2) { + body = append(body, t.style(t.text).Render(" "+line)) + } + body = append(body, + "", + label("workdir", strconv.Quote(workdir)), + label("timeout", strconv.Itoa(p.TimeoutSec)+"s"), + "", + approvalChoices(t, allow), + ) + return renderPanel(t, "APPROVAL REQUIRED", t.danger, body, width) +} + +// approvalChoices brackets the focused choice so the selection survives +// NO_COLOR, and tints allow/deny with their semantic tones. +func approvalChoices(t theme, allow bool) string { + deny, allowLabel := " Deny ", " Allow once " + if allow { + allowLabel = "[ Allow once ]" + } else { + deny = "[ Deny ]" + } + return t.style(t.ok).Render(allowLabel) + " " + t.style(t.danger).Render(deny) +} + +// pickerRow is one selectable line in a menu panel. +type pickerRow struct { + label string + detail string + status string +} + +// renderPicker draws a menu panel. The focused row is marked with a caret and +// an accent tint; the caret carries the selection so it survives NO_COLOR. +func renderPicker(t theme, title string, rows []pickerRow, sel int, width int) string { + inner := width - 4 + body := make([]string, 0, len(rows)+1) + if len(rows) == 0 { + body = append(body, t.style(t.dim).Render("none configured")) + return renderPanel(t, title, t.line, body, width) + } + for i, r := range rows { + caret, tone := " ", t.text + if i == sel { + caret, tone = t.style(t.accent).Render("▸ "), t.accent + } + line := caret + t.style(tone).Render(r.label) + if r.detail != "" { + line += t.style(t.dim).Render(" " + r.detail) + } + if r.status != "" { + dot := t.style(t.statusTone(r.status)).Render(statusSymbol(r.status)) + status := dot + t.style(t.dim).Render(" "+r.status) + line = padTo(line, inner-lipgloss.Width(r.status)-2) + status + } + body = append(body, padTo(line, inner)) + } + return renderPanel(t, title, t.line, body, width) +} + +// footerHints lists the few keys that actually work in the current mode. +// Progressive disclosure: the floor is always visible, the rest lives in /help. +func footerHints(mode uiMode) []string { + switch mode { + case modeApproval: + return []string{"←/→ select", "enter confirm", "esc deny"} + case modeProviderPick, modeMCPPick, modeCredSourcePick, modeCredModelPick: + return []string{"↑/↓ move", "enter select", "esc cancel"} + case modeProviderKey, modeMCPKey, modeCredName: + return []string{"enter save", "esc cancel"} + default: + return []string{"enter send", "/ commands", "^c quit"} + } +} + +func renderFooter(t theme, mode uiMode, width int) string { + hints := footerHints(mode) + parts := make([]string, 0, len(hints)) + for _, h := range hints { + parts = append(parts, t.style(t.dim).Render(h)) + } + return truncTo(" "+strings.Join(parts, t.style(t.line).Render(" · ")), width) +} + +// renderActivity is the streaming indicator. It occupies the assistant gutter +// so text lands where the spinner was, with no layout jump. +func renderActivity(t theme, busy bool, frame string) string { + if !busy { + return "" + } + return t.style(t.text).Render(gutterBar+"abox") + + t.style(t.accent).Render(" "+frame) + + t.style(t.dim).Render(" thinking…") +} + +// activityVisible reports whether the spinner line should be drawn. Once the +// assistant's text starts arriving it occupies the same gutter, so showing +// both would render the speaker twice for the whole response. +func activityVisible(entries []entry, busy bool) bool { + if !busy { + return false + } + if n := len(entries); n > 0 { + last := entries[n-1] + return !(last.kind == entryAssistant && last.text != "") + } + return true +} diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go new file mode 100644 index 0000000..6300122 --- /dev/null +++ b/internal/tui/render_test.go @@ -0,0 +1,325 @@ +package tui + +import ( + "strings" + "testing" + "unicode/utf8" + + "charm.land/lipgloss/v2" + + "github.com/AdminTurnedDevOps/ABox/protocol" +) + +func TestEntriesFromLogClassifiesEachSpeaker(t *testing.T) { + got := entriesFromLog([]string{ + "you: hi", + "", + "Hello there", + " ▸ run_command exit=0", + "error: boom", + "commands:", + }) + want := []entry{ + {kind: entryUser, text: "hi"}, + {kind: entryBlank}, + {kind: entryAssistant, text: "Hello there"}, + {kind: entryTool, text: "run_command exit=0"}, + {kind: entryError, text: "boom"}, + {kind: entryNotice, text: "commands:"}, + } + if len(got) != len(want) { + t.Fatalf("entriesFromLog returned %d entries, want %d: %#v", len(got), len(want), got) + } + for i := range want { + if got[i].kind != want[i].kind || got[i].text != want[i].text { + t.Errorf("entry %d = {%v %q}, want {%v %q}", i, got[i].kind, got[i].text, want[i].kind, want[i].text) + } + } +} + +func TestLogFromEntriesRoundTripsTranscriptFormat(t *testing.T) { + // transcript.json must keep the exact on-disk shape it had before styling. + original := []string{"you: hi", "", "Hello there", " ▸ run_command exit=0", "error: boom"} + if got := logFromEntries(entriesFromLog(original)); !equalStrings(got, original) { + t.Errorf("round trip = %#v, want %#v", got, original) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestRenderHeaderShowsEveryStatusFieldWithinWidth(t *testing.T) { + th := newTheme(false) + h := headerData{model: "xai/grok-4", vm: "ready", net: "direct", key: "key ok"} + got := renderHeader(th, h, 60) + + for _, want := range []string{"ABox", "MODEL", "xai/grok-4", "VM", "ready", "NET", "direct", "KEY", "●"} { + if !strings.Contains(got, want) { + t.Errorf("header missing %q:\n%s", want, got) + } + } + for i, line := range strings.Split(got, "\n") { + if w := lipgloss.Width(line); w > 60 { + t.Errorf("header line %d is %d cells wide, want <= 60: %q", i, w, line) + } + } +} + +func TestRenderHeaderMarksFailedVMWithHollowDot(t *testing.T) { + th := newTheme(false) + got := renderHeader(th, headerData{model: "m", vm: "failed", net: "direct", key: "no key"}, 60) + if !strings.Contains(got, "○") { + t.Errorf("failed vm should render a hollow dot:\n%s", got) + } +} + +func TestRenderHeaderDotsOnlyHealthFields(t *testing.T) { + // NET and MODEL are settings, not health: a status dot there is noise. + got := renderHeader(newTheme(false), headerData{model: "xai/grok-4", vm: "ready", net: "direct", key: "key ok"}, 64) + if strings.Contains(got, "◐") { + t.Errorf("only VM and KEY carry status dots; got a pending dot on a setting:\n%s", got) + } + if strings.Count(got, "●") != 2 { + t.Errorf("want exactly two health dots (VM, KEY), got %d:\n%s", strings.Count(got, "●"), got) + } +} + +func TestRenderHeaderAlignsValueColumns(t *testing.T) { + got := renderHeader(newTheme(false), headerData{model: "xai/grok-4", vm: "ready", net: "direct", key: "key ok"}, 64) + rows := strings.Split(got, "\n")[1:3] + if got, want := strings.Index(rows[0], "xai/grok-4"), strings.Index(rows[1], "direct"); got != want { + t.Errorf("left values start at columns %d and %d, want them aligned:\n%s", got, want, strings.Join(rows, "\n")) + } + if got, want := strings.Index(rows[0], "ready"), strings.Index(rows[1], "key ok"); got != want { + t.Errorf("right values start at columns %d and %d, want them aligned:\n%s", got, want, strings.Join(rows, "\n")) + } +} + +func TestWrapCellsKeepsMultibyteRunesIntact(t *testing.T) { + // The old byte-sliced wrap cut multibyte runes in half at the width bound. + got := wrapCells(strings.Repeat("日本語", 12), 20) + for i, line := range got { + if !utf8.ValidString(line) { + t.Errorf("line %d is not valid UTF-8: %q", i, line) + } + if w := lipgloss.Width(line); w > 20 { + t.Errorf("line %d is %d cells wide, want <= 20: %q", i, w, line) + } + } +} + +func TestWrapCellsBreaksOnWordBoundary(t *testing.T) { + got := wrapCells("the quick brown fox jumps", 12) + if len(got) < 2 { + t.Fatalf("expected a wrap, got %#v", got) + } + if strings.HasSuffix(got[0], " ") || strings.HasPrefix(got[1], " ") { + t.Errorf("wrap should trim the break space: %#v", got) + } +} + +func TestRenderTranscriptGivesEachSpeakerAGutter(t *testing.T) { + th := newTheme(false) + got := renderTranscript(th, []entry{ + {kind: entryUser, text: "hi"}, + {kind: entryAssistant, text: "hello back"}, + {kind: entryTool, text: "run_command exit=0"}, + {kind: entryError, text: "boom"}, + }, 60, 40) + + for _, want := range []string{"▎you", "hi", "▎abox", "hello back", "▎tool", "run_command", "▎error", "boom"} { + if !strings.Contains(got, want) { + t.Errorf("transcript missing %q:\n%s", want, got) + } + } +} + +func TestRenderTranscriptRespectsHeightAndWidth(t *testing.T) { + var entries []entry + for i := 0; i < 50; i++ { + entries = append(entries, entry{kind: entryAssistant, text: strings.Repeat("word ", 30)}) + } + got := renderTranscript(newTheme(false), entries, 40, 10) + lines := strings.Split(got, "\n") + if len(lines) > 10 { + t.Errorf("transcript rendered %d lines, want <= 10", len(lines)) + } + for i, line := range lines { + if w := lipgloss.Width(line); w > 40 { + t.Errorf("line %d is %d cells wide, want <= 40: %q", i, w, line) + } + } +} + +func TestRenderApprovalShowsCommandContext(t *testing.T) { + got := renderApproval(newTheme(false), protocol.RunCommandApprovalParams{ + Command: "curl -s https://api.example.com", WorkDir: "/work/repo", TimeoutSec: 60, + }, false, 60) + + for _, want := range []string{"APPROVAL REQUIRED", "run_command", "curl -s https://api.example.com", "/work/repo", "60s"} { + if !strings.Contains(got, want) { + t.Errorf("approval dialog missing %q:\n%s", want, got) + } + } + for i, line := range strings.Split(got, "\n") { + if w := lipgloss.Width(line); w > 60 { + t.Errorf("line %d is %d cells wide, want <= 60: %q", i, w, line) + } + } +} + +func TestRenderApprovalMarksTheSelectedChoice(t *testing.T) { + p := protocol.RunCommandApprovalParams{Command: "ls", WorkDir: ".", TimeoutSec: 5} + denied := renderApproval(newTheme(false), p, false, 60) + allowed := renderApproval(newTheme(false), p, true, 60) + + if !strings.Contains(denied, "[ Deny ]") { + t.Errorf("deny selection should be bracketed:\n%s", denied) + } + if !strings.Contains(allowed, "[ Allow once ]") { + t.Errorf("allow selection should be bracketed:\n%s", allowed) + } + if denied == allowed { + t.Error("selection state must change the rendering") + } +} + +func TestRenderApprovalDefaultsWorkdirToRepoRoot(t *testing.T) { + got := renderApproval(newTheme(false), protocol.RunCommandApprovalParams{Command: "ls", TimeoutSec: 5}, false, 60) + if !strings.Contains(got, protocol.GuestRepoDir) { + t.Errorf("empty workdir should display the guest repo root %q:\n%s", protocol.GuestRepoDir, got) + } +} + +func TestRenderPickerMarksSelectedRow(t *testing.T) { + rows := []pickerRow{ + {label: "Grok (xAI)", status: "key ok"}, + {label: "OpenAI", status: "no key"}, + {label: "Anthropic", status: "no key"}, + } + got := renderPicker(newTheme(false), "Select provider", rows, 1, 60) + + for _, want := range []string{"Select provider", "Grok (xAI)", "OpenAI", "Anthropic"} { + if !strings.Contains(got, want) { + t.Errorf("picker missing %q:\n%s", want, got) + } + } + lines := strings.Split(got, "\n") + var selected, unselected string + for _, l := range lines { + if strings.Contains(l, "OpenAI") { + selected = l + } + if strings.Contains(l, "Anthropic") { + unselected = l + } + } + if !strings.Contains(selected, "▸") { + t.Errorf("selected row should carry a caret: %q", selected) + } + if strings.Contains(unselected, "▸") { + t.Errorf("unselected row should not carry a caret: %q", unselected) + } +} + +func TestRenderPickerHandlesEmptyList(t *testing.T) { + got := renderPicker(newTheme(false), "MCP servers", nil, 0, 60) + if !strings.Contains(got, "none configured") { + t.Errorf("empty picker should say so:\n%s", got) + } +} + +func TestRenderFooterIsContextual(t *testing.T) { + th := newTheme(false) + chat := renderFooter(th, modeChat, 60) + approval := renderFooter(th, modeApproval, 60) + + if !strings.Contains(chat, "commands") { + t.Errorf("chat footer should advertise the command menu:\n%s", chat) + } + if !strings.Contains(approval, "deny") { + t.Errorf("approval footer should mention denial:\n%s", approval) + } + if chat == approval { + t.Error("footer hints must change with mode") + } + if w := lipgloss.Width(approval); w > 60 { + t.Errorf("footer is %d cells wide, want <= 60", w) + } +} + +func TestRenderActivityOnlyShowsWhileBusy(t *testing.T) { + th := newTheme(false) + if got := renderActivity(th, false, "⠼"); got != "" { + t.Errorf("idle activity line = %q, want empty", got) + } + got := renderActivity(th, true, "⠼") + if !strings.Contains(got, "⠼") || !strings.Contains(got, "thinking") { + t.Errorf("busy activity line = %q, want the spinner frame and a label", got) + } + if !strings.Contains(got, gutterBar) { + t.Errorf("activity line should align with the speaker gutter: %q", got) + } +} + +func TestRenderApprovalEscapesControlCharactersSoTheDialogCannotBeForged(t *testing.T) { + // A command carrying newlines and box-drawing could otherwise paint fake + // dialog rows and trick the operator into approving something else. + forged := "ls\n┃ [ Allow once ] ┃\ntimeout 1s" + got := renderApproval(newTheme(false), protocol.RunCommandApprovalParams{ + Command: forged, WorkDir: ".", TimeoutSec: 5, + }, false, 60) + + if strings.Contains(got, "ls\n┃") { + t.Errorf("raw newline from the command reached the dialog:\n%s", got) + } + if !strings.Contains(got, `\n`) { + t.Errorf("newlines should render escaped:\n%s", got) + } + if n := strings.Count(got, "[ Deny ]"); n != 1 { + t.Errorf("dialog shows %d deny buttons, want exactly 1:\n%s", n, got) + } +} + +func TestTruncToNeverCutsInsideAnEscapeSequence(t *testing.T) { + th := newTheme(true) + styled := th.style(th.accent).Render("abcdefghij") + th.style(th.danger).Render("klmnop") + got := truncTo(styled, 8) + if strings.Count(got, "\x1b")%2 != 0 { + t.Errorf("truncation left a dangling escape sequence: %q", got) + } + if w := lipgloss.Width(got); w > 8 { + t.Errorf("truncated width = %d, want <= 8: %q", w, got) + } +} + +func TestActivityHidesOnceTextStartsArriving(t *testing.T) { + // Streaming text already occupies the assistant gutter; a second + // "thinking" line beneath it renders a duplicate speaker. + streaming := []entry{{kind: entryUser, text: "hi"}, {kind: entryBlank}, {kind: entryAssistant, text: "partial repl"}} + if activityVisible(streaming, true) { + t.Error("activity line should be hidden while assistant text is streaming") + } + + waiting := []entry{{kind: entryUser, text: "hi"}, {kind: entryBlank}} + if !activityVisible(waiting, true) { + t.Error("activity line should show while waiting for the first token") + } + if activityVisible(waiting, false) { + t.Error("activity line should never show when idle") + } + + afterTool := []entry{{kind: entryTool, text: "run_command exit=0"}} + if !activityVisible(afterTool, true) { + t.Error("activity line should show while the model works after a tool call") + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..ed34019 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,99 @@ +package tui + +import ( + "image/color" + "os" + + "charm.land/lipgloss/v2" +) + +// theme is the instrument-panel palette. Colors are named by role, not by +// appearance, so the same render code serves the colored and NO_COLOR paths. +type theme struct { + colored bool + + ground color.Color // page background + surface color.Color // panel fill, one step above ground + line color.Color // idle border + lineFocus color.Color // focused border + text color.Color // body copy + dim color.Color // secondary copy, labels + accent color.Color // brand, user gutter, focus + tool color.Color // tool activity + ok color.Color // healthy state + danger color.Color // errors, denial +} + +func newTheme(colored bool) theme { + return theme{ + colored: colored, + ground: lipgloss.Color("#0B0E11"), + surface: lipgloss.Color("#12161B"), + line: lipgloss.Color("#232A31"), + lineFocus: lipgloss.Color("#3FD5C7"), + text: lipgloss.Color("#E6EDF3"), + dim: lipgloss.Color("#7D8B99"), + accent: lipgloss.Color("#3FD5C7"), + tool: lipgloss.Color("#E3B341"), + ok: lipgloss.Color("#56D364"), + danger: lipgloss.Color("#F85149"), + } +} + +// colorEnabled honors NO_COLOR unconditionally: any non-empty value disables +// color. https://no-color.org +func colorEnabled() bool { + return os.Getenv("NO_COLOR") == "" +} + +// style returns a foreground style, or a bare style when color is disabled so +// no escape sequences reach the terminal. +func (t theme) style(fg color.Color) lipgloss.Style { + if !t.colored { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(fg) +} + +// statusSymbol pairs every state with a glyph so status survives NO_COLOR; +// color reinforces the symbol rather than carrying the meaning alone. +func statusSymbol(state string) string { + switch state { + case "ready", "key ok", "ok", "token ok": + return "●" + case "failed", "unavailable", "no key", "no token": + return "○" + default: + return "◐" + } +} + +// statusTone maps a state to the color that reinforces its symbol. +func (t theme) statusTone(state string) color.Color { + switch statusSymbol(state) { + case "●": + return t.ok + case "○": + return t.danger + default: + return t.dim + } +} + +// canvas paints the page ground so the alt-screen does not borrow the host +// terminal's background. +func (t theme) canvas() lipgloss.Style { + if !t.colored { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(t.text).Background(t.ground) +} + +// title is the panel heading style: bold only when styling is enabled, so the +// NO_COLOR path emits no escape sequences at all. +func (t theme) title(fg color.Color) lipgloss.Style { + if !t.colored { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(fg).Bold(true) +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 0000000..4602613 --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,46 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestThemeWithoutColorEmitsNoEscapeSequences(t *testing.T) { + th := newTheme(false) + for name, got := range map[string]string{ + "accent": th.style(th.accent).Render("hi"), + "danger": th.style(th.danger).Render("hi"), + "dim": th.style(th.dim).Render("hi"), + } { + if strings.Contains(got, "\x1b") { + t.Errorf("%s style emitted an escape sequence with color disabled: %q", name, got) + } + if got != "hi" { + t.Errorf("%s style = %q, want %q", name, got, "hi") + } + } +} + +func TestThemeWithColorEmitsEscapeSequences(t *testing.T) { + // Guards the inverse: a theme that never colors would pass the test above. + if got := newTheme(true).style(newTheme(true).accent).Render("hi"); !strings.Contains(got, "\x1b") { + t.Errorf("accent style with color enabled = %q, want an escape sequence", got) + } +} + +func TestStatusSymbolReflectsState(t *testing.T) { + cases := map[string]string{ + "ready": "●", + "key ok": "●", + "failed": "○", + "unavailable": "○", + "no key": "○", + "not-started": "◐", + "": "◐", + } + for state, want := range cases { + if got := statusSymbol(state); got != want { + t.Errorf("statusSymbol(%q) = %q, want %q", state, got, want) + } + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 64e5eda..16d17c2 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,15 +3,14 @@ package tui import ( "context" "fmt" - "strconv" "strings" "time" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/AdminTurnedDevOps/ABox/internal/config" "github.com/AdminTurnedDevOps/ABox/internal/credsource" @@ -66,6 +65,7 @@ type model struct { approvalReq *runCommandApprovalRequest approvalAllow bool approvals chan *runCommandApprovalRequest + spin spinner.Model } type evMsg protocol.AgentEvent @@ -95,6 +95,13 @@ func New(cfg config.File, sel config.Model, sb *runtime.Sandbox, broker *hostbro ta.Focus() ta.SetHeight(3) ta.ShowLineNumbers = false + // Mark only the first line, so continuation rows read as one input field. + ta.SetPromptFunc(2, func(info textarea.PromptInfo) string { + if info.LineNumber == 0 { + return "› " + } + return " " + }) ta.KeyMap.InsertNewline = key.NewBinding( key.WithKeys("shift+enter", "alt+enter"), key.WithHelp("shift+enter", "newline"), @@ -104,8 +111,9 @@ func New(cfg config.File, sel config.Model, sb *runtime.Sandbox, broker *hostbro ki.EchoCharacter = '•' ki.Placeholder = "paste API key" ki.Prompt = "key> " + sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) return model{ - cfg: cfg, sel: sel, sandbox: sb, hostBroker: broker, ta: ta, keyIn: ki, + cfg: cfg, sel: sel, sandbox: sb, hostBroker: broker, ta: ta, keyIn: ki, spin: sp, vmState: vmState, log: log, transcriptPath: transcriptPath, approvals: make(chan *runCommandApprovalRequest), } @@ -152,6 +160,13 @@ func credStatusLabel(ctx context.Context, r *credsource.Resolver, ref config.Cre func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case spinner.TickMsg: + if !m.busy { + return m, nil + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.ta.SetWidth(max(20, m.width-4)) @@ -439,7 +454,7 @@ func (m model) submit() (tea.Model, tea.Cmd) { }) close(ch) }() - return m, waitEvent(ch) + return m, tea.Batch(waitEvent(ch), m.spin.Tick) } func (m model) runSlash(text string) (tea.Model, tea.Cmd) { @@ -781,146 +796,158 @@ func (m *model) appendLast(s string) { m.log[len(m.log)-1] = last + s } +// headerHeight is the rail: top rule, two field rows, and the body seam. +const headerHeight = 4 + +func (m model) theme() theme { return newTheme(colorEnabled()) } + +func (m model) credState() string { + if m.selKeyStatus == "" { + return "checking" + } + return m.selKeyStatus +} + func (m model) View() tea.View { - canvas := lipgloss.NewStyle().Foreground(lipgloss.Color("#F4F4F5")).Background(lipgloss.Color("#050505")) - muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#71717A")) - bar := lipgloss.NewStyle().Foreground(lipgloss.Color("#F4F4F5")).Background(lipgloss.Color("#141416")).Padding(0, 1) + th := m.theme() + width, height := m.width, m.height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } - cred := m.selKeyStatus - if cred == "" { - cred = "…" + header := renderHeader(th, headerData{ + model: m.sel.Provider + "/" + m.sel.Model, + vm: m.vmState, + net: m.cfg.Connectivity.Mode, + key: m.credState(), + }, width) + + lower := m.renderLower(th, width) + bodyH := height - headerHeight - len(strings.Split(lower, "\n")) - 2 + if bodyH < 3 { + bodyH = 3 + } + + content := th.canvas().Render(strings.Join([]string{ + header, + m.renderBody(th, width, bodyH), + lower, + renderFooter(th, m.mode, width), + }, "\n")) + + v := tea.NewView(content) + v.AltScreen = true + if th.colored { + v.BackgroundColor = th.ground } - header := bar.Render(fmt.Sprintf("ABox %s/%s vm:%s net:%s %s", m.sel.Provider, m.sel.Model, m.vmState, m.cfg.Connectivity.Mode, cred)) + return v +} - wrapW := m.width - if wrapW <= 0 { - wrapW = 80 +// renderBody boxes the transcript directly beneath the header seam. +func (m model) renderBody(th theme, width, height int) string { + inner := width - 4 + entries := entriesFromLog(m.log) + if len(entries) == 0 { + entries = []entry{{kind: entryNotice, text: "Type / for commands."}} + } + lines := strings.Split(renderTranscript(th, entries, inner, height), "\n") + if act := renderActivity(th, activityVisible(entries, m.busy), m.spin.View()); act != "" { + lines = tail(append(lines, act), height) } - bodyH := max(3, m.height-8) - if m.height == 0 { - bodyH = 16 + for len(lines) < height { + lines = append(lines, "") } - wrapped := wrapLog(m.log, wrapW) - body := strings.Join(tail(wrapped, bodyH), "\n") - if body == "" { - body = muted.Render("Type / for commands.") + rule := th.style(th.line) + out := make([]string, 0, height+1) + for _, l := range lines { + out = append(out, rule.Render("│ ")+padTo(l, inner)+rule.Render(" │")) } + return strings.Join(append(out, rule.Render("└"+strings.Repeat("─", width-2)+"┘")), "\n") +} - composer := m.ta.View() - extra := "" - footer := "" +// renderLower is the mode-dependent zone under the transcript: a menu, a +// prompt, the approval dialog, or the composer. +func (m model) renderLower(th theme, width int) string { switch m.mode { - case modeProviderPick: - var b strings.Builder - b.WriteString("Select provider\n") - for i, p := range providerChoices() { - mark := " " - if i == m.provSel { - mark = "> " - } - status := "…" - if s, ok := m.provKeyStatus[p.Name]; ok { - status = s - } - b.WriteString(mark + p.Label + " " + status + "\n") - } - composer = b.String() - case modeProviderKey: - composer = "API key for " + m.provPick.Label + "\n" + m.keyIn.View() - case modeCredSourcePick: - var b strings.Builder - b.WriteString("Credential store (reference only; tokens stay in the environment)\n") - for i, s := range cloudCredentialChoices() { - mark := " " - if i == m.credSourceSel { - mark = "> " - } - b.WriteString(mark + s.Label + "\n") + case modeApproval: + if m.approvalReq != nil { + return renderApproval(th, m.approvalReq.params, m.approvalAllow, width) } - composer = b.String() + case modeProviderPick: + return renderPicker(th, "Select provider", providerRows(m.provKeyStatus), m.provSel, width) case modeCredModelPick: - var b strings.Builder - b.WriteString("Model for " + m.credSource.Label + "\n") - for i, p := range providerChoices() { - mark := " " - if i == m.provSel { - mark = "> " - } - b.WriteString(mark + p.Label + "\n") + return renderPicker(th, "Model for "+m.credSource.Label, providerRows(nil), m.provSel, width) + case modeCredSourcePick: + rows := make([]pickerRow, 0, 3) + for _, c := range cloudCredentialChoices() { + rows = append(rows, pickerRow{label: c.Label, detail: c.Note}) } - composer = b.String() - case modeCredName: - composer = m.credSource.Label + " for " + m.provPick.Label + "\n" + m.keyIn.View() + return renderPicker(th, "Credential store", rows, m.credSourceSel, width) case modeMCPPick: servers := mcpServers(m.cfg) - var b strings.Builder - b.WriteString("MCP servers (OAuth: abox mcp login )\n") - if len(servers) == 0 { - b.WriteString(" none configured\n") + rows := make([]pickerRow, 0, len(servers)) + for _, s := range servers { + rows = append(rows, pickerRow{label: s.Name, detail: s.URL, status: mcpTokenState(m.mcpKeyStatus, s.Name)}) } - for i, s := range servers { - mark := " " - if i == m.mcpSel { - mark = "> " - } - status := "…" - if st, ok := m.mcpKeyStatus[s.Name]; ok { - if st == "key ok" { - status = "token ok" - } else { - status = "no token" - } - } - b.WriteString(mark + s.Name + " " + s.URL + " " + status + "\n") - } - composer = b.String() + return renderPicker(th, "MCP servers", rows, m.mcpSel, width) + case modeProviderKey: + return m.renderPrompt(th, "API key for "+m.provPick.Label, width) case modeMCPKey: - composer = "Bearer token for " + m.mcpPick.Name + "\n" + m.keyIn.View() - case modeApproval: - if m.approvalReq != nil { - workdir := m.approvalReq.params.WorkDir - if workdir == "" { - workdir = "." - } - deny, allow := "[Deny]", " Allow once " - if m.approvalAllow { - deny, allow = " Deny ", "[Allow once]" - } - composer = fmt.Sprintf( - "Approve run_command?\ncommand: %s\nguest workdir: %s\ntimeout: %ds\n\n%s %s\nleft/right or j/k select; enter confirms; esc denies", - strconv.Quote(m.approvalReq.params.Command), strconv.Quote(workdir), m.approvalReq.params.TimeoutSec, deny, allow, - ) - } + return m.renderPrompt(th, "Bearer token for "+m.mcpPick.Name, width) + case modeCredName: + return m.renderPrompt(th, m.credSource.Label+" for "+m.provPick.Label, width) + } + return m.renderComposer(th, width) +} + +func providerRows(status map[string]string) []pickerRow { + choices := providerChoices() + rows := make([]pickerRow, 0, len(choices)) + for _, p := range choices { + rows = append(rows, pickerRow{label: p.Label, status: status[p.Name]}) + } + return rows +} + +func mcpTokenState(status map[string]string, name string) string { + switch status[name] { + case "key ok": + return "token ok" + case "": + return "" default: - if m.showingSlash() { - var b strings.Builder - cmds := filterSlash(m.ta.Value()) - for i, c := range cmds { - mark := " " - if i == m.slashSel { - mark = "> " - } - b.WriteString(mark + c.Name + " " + c.Help + "\n") - } - if b.Len() == 0 { - b.WriteString(" no matching commands\n") - } - extra = b.String() + return "no token" + } +} + +func (m model) renderPrompt(th theme, title string, width int) string { + return renderPanel(th, title, th.lineFocus, []string{padTo(m.keyIn.View(), width-4)}, width) +} + +// renderComposer draws the input box, with the slash menu stacked above it +// when the user is typing a command. +func (m model) renderComposer(th theme, width int) string { + var out []string + if m.showingSlash() { + cmds := filterSlash(m.ta.Value()) + rows := make([]pickerRow, 0, len(cmds)) + for _, c := range cmds { + rows = append(rows, pickerRow{label: c.Name, detail: c.Help}) } + out = append(out, renderPicker(th, "commands", rows, m.slashSel, width)) } - if m.err != "" { - footer = lipgloss.NewStyle().Foreground(lipgloss.Color("#B54A4A")).Render(m.err) + body := make([]string, 0, 3) + for _, l := range strings.Split(m.ta.View(), "\n") { + body = append(body, padTo(l, width-4)) } - - parts := []string{header, "", body, "", extra + composer} - if footer != "" { - parts = append(parts, footer) + out = append(out, renderPanel(th, "input", th.lineFocus, body, width)) + if m.err != "" { + out = append(out, th.style(th.danger).Render(" "+truncTo(m.err, width-1))) } - content := canvas.Render(strings.Join(parts, "\n")) - v := tea.NewView(content) - v.AltScreen = true - v.BackgroundColor = lipgloss.Color("#050505") - return v + return strings.Join(out, "\n") } func formatToolLine(tool, status, text, errText string) string { @@ -936,40 +963,6 @@ func formatToolLine(tool, status, text, errText string) string { } } -func wrapLog(lines []string, width int) []string { - if width < 8 { - width = 8 - } - var out []string - for _, line := range lines { - out = append(out, wrapLine(line, width)...) - } - return out -} - -func wrapLine(s string, width int) []string { - s = strings.ReplaceAll(s, "\t", " ") - if s == "" { - return []string{""} - } - var lines []string - for _, para := range strings.Split(s, "\n") { - for len(para) > width { - cut := strings.LastIndex(para[:width], " ") - if cut < width/4 { - cut = width - } - lines = append(lines, strings.TrimRight(para[:cut], " ")) - para = strings.TrimLeft(para[cut:], " ") - } - lines = append(lines, para) - } - if len(lines) == 0 { - return []string{""} - } - return lines -} - func tail(in []string, n int) []string { if len(in) <= n { return in