Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/mecatui/quieter_toolcalls_scenario3_rebind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ func TestMecatuiQuieterToolCalls_Scenario3_ConfiguredFooterAndShortcuts(t *testi
m = updated.(ui.Model)
}
view := ansi.Strip(m.View().Content)
if !strings.Contains(view, "ctrl+f11 tool calls · ctrl+f12 session details · ctrl+c quit") || strings.Contains(view, "ctrl+f10 tool calls") || strings.Contains(view, "ctrl+g select all") || strings.Contains(view, "ctrl+y copy") || strings.Contains(view, "ctrl+u clear") || strings.Contains(view, "● mecatl") {
if !strings.Contains(view, "ctrl+f11 tool calls · ctrl+f12 session details · ctrl+c quit") || strings.Contains(view, "ctrl+f10 tool calls") || strings.Contains(view, "ctrl+g select all") || strings.Contains(view, "ctrl+y copy") || strings.Contains(view, "ctrl+u clear") || strings.Contains(view, "◀ mecatl") {
t.Fatalf("effective settings/CLI shortcuts missing from footer: %s", view)
}
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF12, Mod: tea.ModCtrl})
m = updated.(ui.Model)
if view := ansi.Strip(m.View().Content); !strings.Contains(view, "● mecatl") {
if view := ansi.Strip(m.View().Content); !strings.Contains(view, "◀ mecatl") {
t.Fatalf("configured ExpandConversation chord did not reveal speaker: %s", view)
}
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyF11, Mod: tea.ModCtrl})
Expand Down
73 changes: 73 additions & 0 deletions cmd/mecatui/startup_resume_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"

"github.com/stacklok/mecatl/adapters/jsonlstore"
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/embed"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
Expand Down Expand Up @@ -104,6 +105,78 @@ func TestSessionContinuityUX_Scenario6_EmbeddedAndConnectE2E(t *testing.T) {
}
}

func TestStartupResumeHidesPersistedHarnessControlNotesE2E(t *testing.T) {
starters := []struct {
name string
start func(*testing.T, app.Config) (string, func())
}{
{name: "embedded", start: startStartupResumeEmbedded},
{name: "connect", start: startStartupResumeConnect},
}
for _, tc := range starters {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
workspace, storeDir, userModelDir := t.TempDir(), t.TempDir(), t.TempDir()
cfg := app.Config{Workspace: workspace, StoreDir: storeDir, UserModelDir: userModelDir, Model: "mock-model", UseMock: true, Shell: "/bin/sh", Compaction: "heuristic", Tokenizer: "heuristic"}
seed, err := jsonlstore.New(storeDir)
if err != nil {
t.Fatalf("open seed store: %v", err)
}
sess := session.New("persisted-harness-control", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindLocal, ID: workspace, Revision: "in-tree-v1"}, session.Limits{}, time.Unix(1, 0))
const backgroundCompletion = "[harness note: 1 background subagent(s) finished: subagent-job-123 (end_turn). Collect each result with SubagentStatus before relying on it.]"
if err := sess.SeedHistory([]session.Message{
{Role: session.RoleUser, Text: "retain this prompt", UserPromptProvenance: session.UserPromptProvenancePrincipal},
{Role: session.RoleUser, Text: session.NoProgressNudgeText, UserPromptProvenance: session.UserPromptProvenanceHarness},
{Role: session.RoleUser, Text: backgroundCompletion, UserPromptProvenance: session.UserPromptProvenanceHarness},
{Role: session.RoleUser, Text: "[harness note: background command(s) still running: build]", UserPromptProvenance: session.UserPromptProvenanceHarness},
{Role: session.RoleAssistant, Text: "retain this answer"},
}); err != nil {
t.Fatalf("seed history: %v", err)
}
if err := seed.Save(ctx, sess); err != nil {
t.Fatalf("save seed session: %v", err)
}

_, stop := tc.start(t, cfg)
stop()
target, stop := tc.start(t, cfg)
defer stop()
cl, err := client.Dial(client.DialConfig{Server: target})
if err != nil {
t.Fatalf("dial restarted server: %v", err)
}
defer func() { _ = cl.Close() }()
selection, err := resolveStartupResume(ctx, cl, string(sess.ID), false)
if err != nil {
t.Fatalf("resolve startup resume: %v", err)
}
if !selection.Transcript.Complete || len(selection.Transcript.Messages) != 2 ||
selection.Transcript.Messages[0].Text != "retain this prompt" || selection.Transcript.Messages[1].Text != "retain this answer" {
t.Fatalf("resumed transcript retained internal control notes or lost messages: %+v", selection.Transcript)
}
m := ui.New(ui.Deps{
Session: &sessionAdapter{cl: cl, mode: "default"}, Conv: cl, Transcript: cl,
Theme: theme.New("aztec", theme.AztecPalette()), Workspace: workspace, Mode: "default", Model: "mock-model",
Ctx: ctx, NoAltScreen: true, NoBanner: true, Resume: selection,
})
resized, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 35})
m = resized.(ui.Model)
content := m.View().Content
for _, want := range []string{"retain this prompt", "retain this", "answer"} {
if !strings.Contains(content, want) {
t.Fatalf("resumed UI omitted retained content %q:\n%s", want, content)
}
}
for _, hidden := range []string{"Please continue.", "subagent-job-123", "background command(s) still running: build"} {
if strings.Contains(content, hidden) {
t.Fatalf("resumed UI displayed harness control note %q:\n%s", hidden, content)
}
}
})
}
}

func TestSessionsCommand_EmbeddedAndConnectE2E(t *testing.T) {
starters := []struct {
name string
Expand Down
12 changes: 12 additions & 0 deletions cmd/mecatui/theme/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ const (
solarGold = "#B58900" // solar accent / user / code / number
)

var aztecTranscript = transcriptRecipe{
llm: "#c98f9d", tool: "#57b8bf", user: "#e9b949", harness: "#aaa0d4",
text: "#e7e2d3", harnessText: "#bac8bf", run: "#6b857a",
reasoning: "#9aaba0", stats: "#9aaba0",
}

var solarTranscript = transcriptRecipe{
llm: "#a13d62", tool: "#006f7a", user: "#955b00", harness: "#713bb0",
text: "#202d28", harnessText: "#40554a", run: "#687b70",
reasoning: "#52665b", stats: "#52665b",
}

// aztecPalette is the default mecatui palette: jade/turquoise + gold on obsidian,
// terracotta for errors, bone-coloured text. Tuned for dark terminals. It is
// also the merge base for partial user themes (see load.go), so every slot here
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/theme/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ const defaultName = "aztec"
// the lowest layer of the resolution order; user JSON themes (load.go) override
// or extend it.
var builtins = map[string]Theme{
"aztec": New("aztec", aztecPalette),
"aztec": newTheme("aztec", aztecPalette, aztecTranscript),
"mono": New("mono", monoPalette),
"solar": New("solar", solarPalette),
"solar": newTheme("solar", solarPalette, solarTranscript),
}

// Registry holds a set of themes by name and answers Get/List/Default. main.go
Expand Down
66 changes: 56 additions & 10 deletions cmd/mecatui/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,41 @@ type Theme struct {
Name string
Palette Palette

styles map[string]lipgloss.Style // derived in compile()
slots map[string]string // slot-name → hex, derived in compile()
styles map[string]lipgloss.Style // derived in compile()
slots map[string]string // slot-name → hex, derived in compile()
transcript transcriptRecipe
}

// Transcript actor glyphs are text data, separate from their styles.
const (
TranscriptUserGlyph = "▶"
TranscriptLLMGlyph = "◀"
TranscriptToolGlyph = "●"
TranscriptHarnessGlyph = "◆"
)

type transcriptRecipe struct {
llm, tool, user, harness string
text, harnessText, run, reasoning, stats string
}

func transcriptFallback(p Palette) transcriptRecipe {
return transcriptRecipe{
llm: p.Assistant, tool: p.Tool, user: p.User, harness: p.Info,
text: p.Text, harnessText: p.TextMuted, run: p.Border,
reasoning: p.TextMuted, stats: p.TextMuted,
}
}

// New builds a Theme from a name and palette, compiling the derived styles. It
// is the only constructor: it guarantees the styles map is populated so Style()
// never returns a zero value for a known slot.
// is the constructor for programmatic and custom themes, whose transcript roles
// derive from their existing palette slots.
func New(name string, p Palette) Theme {
t := Theme{Name: name, Palette: p}
return newTheme(name, p, transcriptFallback(p))
}

func newTheme(name string, p Palette, transcript transcriptRecipe) Theme {
t := Theme{Name: name, Palette: p, transcript: transcript}
t.compile()
return t
}
Expand Down Expand Up @@ -181,7 +207,9 @@ func (t Theme) Color(slot string) color.Color {
// Style returns the derived lipgloss.Style for a named UI element. Known names:
// header, footer, viewport, userBlock, userLabel, assistantLabel, toolCard,
// toolName, toolArgs, toolOk, toolErr, askCard, askTitle, askArgs, askButton,
// askButtonActive, spinner, muted, warning, dangerPill, errorText. Unknown names
// askButtonActive, spinner, muted, warning, dangerPill, errorText, and the
// transcript{LLM,Tool,User,Harness}{Marker,Heading,Rail,Text}, transcriptRunRail,
// transcriptReasoningText, and transcriptStatistics semantic roles. Unknown names
// return an empty style so callers degrade gracefully rather than panic.
func (t Theme) Style(name string) lipgloss.Style {
if s, ok := t.styles[name]; ok {
Expand Down Expand Up @@ -243,6 +271,7 @@ func buildSlots(p Palette) map[string]string {
// View() never allocates styles in the hot path.
func (t *Theme) compile() {
p := t.Palette
tr := t.transcript
t.slots = buildSlots(p)
rounded := lipgloss.RoundedBorder()
thick := lipgloss.ThickBorder()
Expand Down Expand Up @@ -282,10 +311,7 @@ func (t *Theme) compile() {
"viewport": lipgloss.NewStyle().
Foreground(col(p.Text)),

// User prompt block: gold left bar. NO background tint — the faint panel tint
// belongs ONLY to the input box we type in, never the conversation history (the
// tinted history block read as a distinct, off surface). Conversation user turns
// are the gold rail + the "▌ you" label over the plain viewport surface.
// User-coloured left border over the plain viewport surface.
"userBlock": lipgloss.NewStyle().
Foreground(col(p.Text)).
BorderStyle(lipgloss.NormalBorder()).
Expand All @@ -299,6 +325,26 @@ func (t *Theme) compile() {
Foreground(col(p.Assistant)).
Bold(true),

"transcriptLLMMarker": lipgloss.NewStyle().Foreground(col(tr.llm)),
"transcriptLLMHeading": lipgloss.NewStyle().Foreground(col(tr.llm)).Bold(true),
"transcriptLLMRail": lipgloss.NewStyle().Foreground(col(tr.llm)),
"transcriptLLMText": lipgloss.NewStyle().Foreground(col(tr.text)),
"transcriptToolMarker": lipgloss.NewStyle().Foreground(col(tr.tool)),
"transcriptToolHeading": lipgloss.NewStyle().Foreground(col(tr.tool)).Bold(true),
"transcriptToolRail": lipgloss.NewStyle().Foreground(col(tr.tool)),
"transcriptToolText": lipgloss.NewStyle().Foreground(col(tr.text)),
"transcriptUserMarker": lipgloss.NewStyle().Foreground(col(tr.user)),
"transcriptUserHeading": lipgloss.NewStyle().Foreground(col(tr.user)).Bold(true),
"transcriptUserRail": lipgloss.NewStyle().Foreground(col(tr.user)),
"transcriptUserText": lipgloss.NewStyle().Foreground(col(tr.text)),
"transcriptHarnessMarker": lipgloss.NewStyle().Foreground(col(tr.harness)),
"transcriptHarnessHeading": lipgloss.NewStyle().Foreground(col(tr.harness)).Bold(true),
"transcriptHarnessRail": lipgloss.NewStyle().Foreground(col(tr.harness)),
"transcriptHarnessText": lipgloss.NewStyle().Foreground(col(tr.harnessText)),
"transcriptRunRail": lipgloss.NewStyle().Foreground(col(tr.run)),
"transcriptReasoningText": lipgloss.NewStyle().Foreground(col(tr.reasoning)).Italic(true),
"transcriptStatistics": lipgloss.NewStyle().Foreground(col(tr.stats)),

// Tool-call card.
"toolCard": lipgloss.NewStyle().
BorderStyle(rounded).
Expand Down
Loading
Loading