From aba874e6ecfa7075f2cce703adf57cc36e4634e0 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 18:19:27 -0700 Subject: [PATCH 1/9] feat(mecatui): add terminal title templates Co-Authored-By: mecatl --- cmd/mecatui/client_settings.go | 65 +++++++-- cmd/mecatui/client_settings_test.go | 132 ++++++++++++++++-- .../statusline/source_correction_test.go | 2 +- cmd/mecatui/statusline/source_test.go | 2 +- cmd/mecatui/statusline/template.go | 10 +- cmd/mecatui/statusline/title.go | 72 ++++++++++ .../ui/predictable_session_handles_test.go | 6 +- cmd/mecatui/ui/statusline_test.go | 4 +- 8 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 cmd/mecatui/statusline/title.go diff --git a/cmd/mecatui/client_settings.go b/cmd/mecatui/client_settings.go index 72c2ed589a..a394349de9 100644 --- a/cmd/mecatui/client_settings.go +++ b/cmd/mecatui/client_settings.go @@ -27,13 +27,39 @@ import ( type clientSettings struct { Keymap map[string]string `yaml:"keymap"` StatusCustomization *statusCustomization `yaml:"status_customization"` + TerminalTitle terminalTitleSettings +} + +type terminalTitleSettings struct { + Enabled bool + Template string +} + +type terminalTitleSettingsYAML struct { + Enabled *bool `yaml:"enabled"` + Template string `yaml:"template"` +} + +const shippedTerminalTitleTemplate = "{{if .Session.Title}}{{.Session.Title}} · {{.MainAgent.State}} · mecatui{{else}}mecatui{{end}}" + +func shippedTerminalTitleSettings() terminalTitleSettings { + return terminalTitleSettings{Enabled: true, Template: shippedTerminalTitleTemplate} +} + +func newTitleRenderer(settings terminalTitleSettings) (*statusline.TitleRenderer, error) { + return statusline.NewTitleRenderer(settings.Template) +} + +func defaultClientSettings() clientSettings { + return clientSettings{TerminalTitle: shippedTerminalTitleSettings()} } // clientSettingsYAML is the strict decode shape. A duration stays textual until // after the strict YAML decode so the accepted duration syntax is explicit. type clientSettingsYAML struct { - Keymap map[string]string `yaml:"keymap"` - StatusCustomization *statusCustomizationYAML `yaml:"status_customization"` + Keymap map[string]string `yaml:"keymap"` + StatusCustomization *statusCustomizationYAML `yaml:"status_customization"` + TerminalTitle *terminalTitleSettingsYAML `yaml:"terminal_title"` } type statusCustomizationYAML struct { @@ -127,12 +153,12 @@ func splitKeymap(raw map[string]string) map[string][]string { func readClientSettings() (clientSettings, error) { path := clientSettingsPath(xdgconfig.OSEnv) if path == "" { - return clientSettings{}, nil + return defaultClientSettings(), nil } b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return clientSettings{}, nil + return defaultClientSettings(), nil } return clientSettings{}, fmt.Errorf("read %s: %w", path, err) } @@ -155,7 +181,28 @@ func readClientSettings() (clientSettings, error) { } return clientSettings{}, fmt.Errorf("parsing %s: invalid status_customization configuration", path) } - return clientSettings{Keymap: raw.Keymap, StatusCustomization: status}, nil + title, err := decodeTerminalTitle(raw.TerminalTitle) + if err != nil { + return clientSettings{}, fmt.Errorf("parsing %s: terminal_title.template: %w", path, err) + } + return clientSettings{Keymap: raw.Keymap, StatusCustomization: status, TerminalTitle: title}, nil +} + +func decodeTerminalTitle(raw *terminalTitleSettingsYAML) (terminalTitleSettings, error) { + if raw == nil { + return shippedTerminalTitleSettings(), nil + } + out := shippedTerminalTitleSettings() + if raw.Enabled != nil { + out.Enabled = *raw.Enabled + } + if raw.Template != "" { + out.Template = raw.Template + } + if _, err := newTitleRenderer(out); err != nil { + return terminalTitleSettings{}, err + } + return out, nil } func clientSettingsSchemaError(path string, err error) error { @@ -163,16 +210,16 @@ func clientSettingsSchemaError(path string, err error) error { diagnostic := yamldiag.Classify("parse client settings", err) if diagnostic.HasLocation { - return fmt.Errorf("parsing %s: does not match the expected client settings schema at line %d, column %d (unknown key or type; %s)", path, diagnostic.Line, diagnostic.Column, guidance) + return fmt.Errorf("parsing %s: does not match the expected client settings schema at line %d, column %d (unknown key or type, including terminal_title; %s)", path, diagnostic.Line, diagnostic.Column, guidance) } - return fmt.Errorf("parsing %s: does not match the expected client settings schema (unknown key or type; %s)", path, guidance) + return fmt.Errorf("parsing %s: does not match the expected client settings schema (unknown key or type, including terminal_title; %s)", path, guidance) } func clientKeymapSyntaxError(path string, err error) error { var documentError *yamldiag.DocumentError if errors.As(err, &documentError) && documentError.Location.HasLocation { - return fmt.Errorf("parsing %s: invalid YAML syntax at line %d, column %d (the document must be valid YAML matching the client settings schema)", path, documentError.Location.Line, documentError.Location.Column) + return fmt.Errorf("parsing %s: invalid YAML syntax at line %d, column %d (the document must be valid YAML matching the client settings schema, including terminal_title)", path, documentError.Location.Line, documentError.Location.Column) } - return fmt.Errorf("parsing %s: invalid YAML syntax (the document must be valid YAML matching the client settings schema)", path) + return fmt.Errorf("parsing %s: invalid YAML syntax (the document must be valid YAML matching the client settings schema, including terminal_title)", path) } // readClientKeymap reads the CLIENT-owned settings file diff --git a/cmd/mecatui/client_settings_test.go b/cmd/mecatui/client_settings_test.go index a6a0e88ebf..264942ca51 100644 --- a/cmd/mecatui/client_settings_test.go +++ b/cmd/mecatui/client_settings_test.go @@ -7,10 +7,13 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" "testing" "time" + "github.com/charmbracelet/x/ansi" + "github.com/stacklok/mecatl/cmd/mecatui/client" statusline "github.com/stacklok/mecatl/cmd/mecatui/statusline" "github.com/stacklok/mecatl/cmd/mecatui/ui" @@ -192,8 +195,8 @@ func TestReadClientSettingsIgnoresServerModelsWithoutTitleSlot(t *testing.T) { if err != nil { t.Fatalf("server settings without models.slots.title must not affect client settings: %v", err) } - if !reflect.DeepEqual(got, clientSettings{}) { - t.Fatalf("client settings = %#v, want zero settings when the client file is absent", got) + if !reflect.DeepEqual(got, defaultClientSettings()) { + t.Fatalf("client settings = %#v, want defaults when the client file is absent", got) } } @@ -497,20 +500,125 @@ func TestReadStatusCustomizationRejectsPartialSurfaceVariants(t *testing.T) { } } -func TestBuildStatusSourceConstructsValidatedTemplateSettings(t *testing.T) { - source := buildStatusSource(statusCustomization{Templates: &statusTemplates{Footer: &statusSurfaceTemplates{ - Full: `
{{.Session.Title}}
`, - Compact: `
{{.Session.Title}}
`, - Minimal: `
{{.Session.Title}}
`, - }}}) +func TestADR_0344_Scenario2_DefaultPresentationOmitsHandle(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + settings, err := readClientSettings() + if err != nil { + t.Fatalf("read absent settings: %v", err) + } + title, err := newTitleRenderer(settings.TerminalTitle) + if err != nil { + t.Fatalf("build shipped title renderer: %v", err) + } + got, err := title.Render(statusline.Input{Session: statusline.Session{Handle: "session-123"}}) + if err != nil { + t.Fatalf("render shipped title: %v", err) + } + if got != "mecatui" { + t.Fatalf("shipped title = %q, want fallback without session handle", got) + } + + source := statusline.NewDefaultSource(0) t.Cleanup(func() { _ = source.Close(context.Background()) }) - source.Submit(statusline.Input{Session: statusline.Session{Title: "configured"}, Terminal: statusline.Terminal{FooterAvailCols: 80}}) + source.Submit(statusline.Input{Session: statusline.Session{Handle: "session-123"}, Terminal: statusline.Terminal{HeaderAvailCols: 80}}) select { case <-source.Changed(): case <-time.After(time.Second): - t.Fatal("configured template source did not publish") + t.Fatal("default status source did not publish") + } + if got := source.Latest().Header.Spans[0].Text; strings.Contains(got, "session-123") { + t.Fatalf("shipped status header leaked session handle: %q", got) + } +} + +func TestADR_0344_Scenario2_SharedTemplateProjectionAndElide(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + writeSettings(t, "mecatui", "terminal_title:\n template: '{{.Session.Handle}} {{elide 4 .Session.Title}}'\nstatus_customization:\n templates:\n header:\n full: '
{{elide 4 .Session.Title}}
'\n compact: '
{{elide 4 .Session.Title}}
'\n minimal: '
{{elide 4 .Session.Title}}
'\n") + + settings, err := readClientSettings() + if err != nil { + t.Fatalf("read configured settings: %v", err) + } + title, err := newTitleRenderer(settings.TerminalTitle) + if err != nil { + t.Fatalf("build title renderer: %v", err) + } + input := statusline.Input{Session: statusline.Session{Handle: "session-123", Title: "abcdef"}, Terminal: statusline.Terminal{HeaderAvailCols: 80}} + if got, err := title.Render(input); err != nil || got != "session-123 abc…" { + t.Fatalf("custom title = %q, %v; want %q", got, err, "session-123 abc…") + } + for _, tc := range []struct { + width int + want string + }{{0, ""}, {-1, ""}, {6, "abcdef"}, {1, "…"}, {4, "abc…"}} { + renderer, err := newTitleRenderer(terminalTitleSettings{Enabled: true, Template: "{{elide " + strconv.Itoa(tc.width) + " .Session.Title}}"}) + if err != nil { + t.Fatalf("build width %d renderer: %v", tc.width, err) + } + got, err := renderer.Render(input) + if err != nil || got != tc.want || ansi.StringWidth(got) > max(tc.width, 0) { + t.Errorf("elide(%d) = %q, %v (width %d), want %q within %d", tc.width, got, err, ansi.StringWidth(got), tc.want, max(tc.width, 0)) + } + } + wideRenderer, err := newTitleRenderer(terminalTitleSettings{Enabled: true, Template: "{{elide 5 .Session.Title}}"}) + if err != nil { + t.Fatalf("build wide-character renderer: %v", err) + } + if got, err := wideRenderer.Render(statusline.Input{Session: statusline.Session{Title: "界界界"}}); err != nil || got != "界界…" || ansi.StringWidth(got) > 5 { + t.Fatalf("wide elide = %q, %v (width %d), want %q within 5", got, err, ansi.StringWidth(got), "界界…") + } + + source := newSource(*settings.StatusCustomization) + t.Cleanup(func() { _ = source.Close(context.Background()) }) + source.Submit(input) + select { + case <-source.Changed(): + case <-time.After(time.Second): + t.Fatal("template status source did not publish") + } + if got := source.Latest().Header.Spans[0].Text; got != "abc…" { + t.Fatalf("status template elide = %q, want %q", got, "abc…") + } +} + +func TestADR_0344_Scenario2_InvalidConfigurationFailsActionably(t *testing.T) { + for _, tc := range []struct{ name, body, want string }{ + {"unknown field", "terminal_title:\n unexpected: true\n", "terminal_title"}, + {"invalid YAML", "terminal_title: [\n", "terminal_title"}, + {"parse failure", "terminal_title:\n template: '{{'\n", "terminal_title.template"}, + {"execution failure", "terminal_title:\n template: '{{index .Session.Title 1}}'\n", "terminal_title.template"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + writeSettings(t, "mecatui", tc.body) + if _, err := readClientSettings(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("readClientSettings() error = %v, want actionable %q error", err, tc.want) + } + }) + } +} + +func TestADR_0344_Scenario2_CommandStatusCannotControlTitle(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + writeSettings(t, "mecatui", "terminal_title:\n template: '{{.Session.Title}} · mecatui'\nstatus_customization:\n command:\n executable: /bin/echo\n args: ['
command title
']\n") + + settings, err := readClientSettings() + if err != nil { + t.Fatalf("read settings: %v", err) + } + if settings.StatusCustomization == nil || settings.StatusCustomization.Command == nil { + t.Fatal("test setup must select the command status source") + } + title, err := newTitleRenderer(settings.TerminalTitle) + if err != nil { + t.Fatalf("build title renderer: %v", err) + } + got, err := title.Render(statusline.Input{Session: statusline.Session{Title: "trusted title"}}) + if err != nil { + t.Fatalf("render title: %v", err) } - if got, want := source.Latest().Footer.Spans[0].Text, "configured"; got != want { - t.Fatalf("configured template text = %q, want %q", got, want) + if got != "trusted title · mecatui" || strings.Contains(got, "command title") { + t.Fatalf("command-backed status influenced title %q", got) } } diff --git a/cmd/mecatui/statusline/source_correction_test.go b/cmd/mecatui/statusline/source_correction_test.go index c1973222ad..8940baf87f 100644 --- a/cmd/mecatui/statusline/source_correction_test.go +++ b/cmd/mecatui/statusline/source_correction_test.go @@ -50,7 +50,7 @@ func TestStatusLine_DefaultTemplatesExposeLegacyDisplayAtoms(t *testing.T) { t.Fatal("source did not publish") } line := source.Latest() - if got, want := statusSurfaceText(line.Header), "mecatui · session deadbeef · openai/GPT-5/azure · mode plan · server.example"; got != want { + if got, want := statusSurfaceText(line.Header), "mecatui · openai/GPT-5/azure · mode plan · server.example"; got != want { t.Fatalf("header = %q, want %q", got, want) } if got := statusSurfaceText(line.Footer); !strings.Contains(got, "⑂ parallel 1◐ 2✓") || !strings.Contains(got, "⛭ subagents 3◐ 4✓") || !strings.Contains(got, "⟳ team-abc · 1/2 working") || !strings.Contains(got, "ctx ▓▓▓▓▓▓░░ 70% · 7K/10K") || !strings.Contains(got, "↑4K ↓1K ⊕500 cache 75%") { diff --git a/cmd/mecatui/statusline/source_test.go b/cmd/mecatui/statusline/source_test.go index c3888c3d77..f991cc86b7 100644 --- a/cmd/mecatui/statusline/source_test.go +++ b/cmd/mecatui/statusline/source_test.go @@ -142,7 +142,7 @@ func TestStatusLine_CompactHeaderLabelsPermissionMode(t *testing.T) { case <-time.After(time.Second): t.Fatal("shipped source did not publish") } - if got, want := statusSurfaceText(s.Latest().Header), "mecatui · deadbeef · GPT-5 · mode accept-edits"; got != want { + if got, want := statusSurfaceText(s.Latest().Header), "mecatui · GPT-5 · mode accept-edits"; got != want { t.Fatalf("compact header = %q, want %q", got, want) } } diff --git a/cmd/mecatui/statusline/template.go b/cmd/mecatui/statusline/template.go index 580c9ac660..030bc0fa54 100644 --- a/cmd/mecatui/statusline/template.go +++ b/cmd/mecatui/statusline/template.go @@ -62,11 +62,7 @@ func firstTemplate(given, fallback string) string { return fallback } func parseStatusTemplate(name, source, fallback string) statusTemplate { - funcs := template.FuncMap{ - "contextMeter": contextMeter, - "contextMeterCompact": contextMeterCompact, - "contextMeterMinimal": contextMeterMinimal, - } + funcs := templateFuncs() fallbackTemplate, err := template.New(name).Funcs(funcs).Option("missingkey=error").Parse(fallback) if err != nil { return statusTemplate{} @@ -120,8 +116,8 @@ func statusSurfaceText(surface Surface) string { } func defaultHeaderTemplates() SurfaceTemplates { return SurfaceTemplates{ - Full: `
mecatui · session {{.Session.Handle}} · {{if .Model.ProviderID}}{{.Model.ProviderID}}/{{end}}{{.Model.DisplayName}}{{if .Model.Route}}/{{.Model.Route}}{{end}}{{if .Session.Mode}} · mode {{.Session.Mode}}{{end}}{{if .Server.DisplayTarget}} · {{.Server.DisplayTarget}}{{end}}
`, - Compact: `
mecatui · {{.Session.Handle}} · {{.Model.DisplayName}}{{if .Session.Mode}} · mode {{.Session.Mode}}{{end}}
`, + Full: `
mecatui · {{if .Model.ProviderID}}{{.Model.ProviderID}}/{{end}}{{.Model.DisplayName}}{{if .Model.Route}}/{{.Model.Route}}{{end}}{{if .Session.Mode}} · mode {{.Session.Mode}}{{end}}{{if .Server.DisplayTarget}} · {{.Server.DisplayTarget}}{{end}}
`, + Compact: `
mecatui · {{.Model.DisplayName}}{{if .Session.Mode}} · mode {{.Session.Mode}}{{end}}
`, Minimal: `
mecatui
`, } } diff --git a/cmd/mecatui/statusline/title.go b/cmd/mecatui/statusline/title.go new file mode 100644 index 0000000000..db99ef2134 --- /dev/null +++ b/cmd/mecatui/statusline/title.go @@ -0,0 +1,72 @@ +package statusline + +import ( + "html" + "strings" + "text/template" + + "github.com/charmbracelet/x/ansi" +) + +// TitleRenderer renders a plain-text title from the display-safe status input. +// It deliberately has no StatusML parsing or command-source integration. +type TitleRenderer struct{ template *template.Template } + +// NewTitleRenderer validates and compiles a title template. The startup render +// catches template expressions which parse successfully but cannot execute +// against the status projection. +func NewTitleRenderer(source string) (*TitleRenderer, error) { + t, err := template.New("terminal_title").Funcs(templateFuncs()).Option("missingkey=error").Parse(source) + if err != nil { + return nil, err + } + var output strings.Builder + if err := t.Execute(&output, newTemplateInput(Input{})); err != nil { + return nil, err + } + return &TitleRenderer{template: t}, nil +} + +// Render renders the configured template as plain text. The terminal-title +// controller owns final terminal-control sanitization and bounds. +func (r *TitleRenderer) Render(input Input) (string, error) { + var output strings.Builder + if err := r.template.Execute(&output, newTemplateInput(input)); err != nil { + return "", err + } + return html.UnescapeString(output.String()), nil +} + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "contextMeter": contextMeter, + "contextMeterCompact": contextMeterCompact, + "contextMeterMinimal": contextMeterMinimal, + "elide": elide, + } +} + +func elide(width int, value any) string { + if width <= 0 { + return "" + } + text := stringifyTemplateValue(value) + if ansi.StringWidth(text) <= width { + return text + } + if width == 1 { + return "…" + } + return ansi.Truncate(text, width, "…") +} + +func stringifyTemplateValue(value any) string { + switch value := value.(type) { + case templateText: + return string(value) + case string: + return value + default: + return "" + } +} diff --git a/cmd/mecatui/ui/predictable_session_handles_test.go b/cmd/mecatui/ui/predictable_session_handles_test.go index 04eb91e487..b85f906d60 100644 --- a/cmd/mecatui/ui/predictable_session_handles_test.go +++ b/cmd/mecatui/ui/predictable_session_handles_test.go @@ -140,7 +140,7 @@ func testPredictableSessionHandle(t *testing.T, checks predictableSessionHandleC st.syncFilter() presentations["sessions"] = stripANSIstr(renderSessionsPanel(testTheme(), st, client.Capabilities{}, helpKeys{}, 100, 30, "")) for name, rendered := range presentations { - if !strings.Contains(rendered, want) || strings.Contains(rendered, "#"+want) || strings.Contains(rendered, "\x1b") { + if (name != "header" && !strings.Contains(rendered, want)) || strings.Contains(rendered, "#"+want) || strings.Contains(rendered, "\x1b") { t.Fatalf("%s does not use terminal-safe shared handle %q: %q", name, want, rendered) } } @@ -176,8 +176,8 @@ func testPredictableSessionHandle(t *testing.T, checks predictableSessionHandleC for _, span := range source.Latest().Header.Spans { shipped.WriteString(span.Text) } - if got := shipped.String(); !strings.Contains(got, "session "+want) || strings.Contains(got, "#"+want) { - t.Fatalf("shipped template does not use bare handle %q: %q", want, got) + if got := shipped.String(); strings.Contains(got, want) || strings.Contains(got, "#"+want) { + t.Fatalf("shipped template leaked opt-in handle %q: %q", want, got) } } diff --git a/cmd/mecatui/ui/statusline_test.go b/cmd/mecatui/ui/statusline_test.go index e6312f5608..3fe703f338 100644 --- a/cmd/mecatui/ui/statusline_test.go +++ b/cmd/mecatui/ui/statusline_test.go @@ -30,7 +30,7 @@ func TestStatusLine_Scenario2_DefaultTemplatesPreserveChrome(t *testing.T) { updated, _ = m.update(waitStatusMessage(t, m.statusLineWaitCmd())) m = updated.(Model) header := stripANSIstr(m.renderHeader()) - for _, want := range []string{"mecatui", "session session-stat", "mode default", "server.example"} { + for _, want := range []string{"mecatui", "mode default", "server.example"} { if !strings.Contains(header, want) { t.Fatalf("header %q is missing shipped chrome %q", header, want) } @@ -131,7 +131,7 @@ func TestStatusLine_Scenario5_DefaultCompatibility(t *testing.T) { t.Fatal("no result") } line := s.Latest() - if got, want := statusSpansText(line.Header.Spans), "mecatui · session deadbeef · openai/GPT-5/azure · mode default"; got != want { + if got, want := statusSpansText(line.Header.Spans), "mecatui · openai/GPT-5/azure · mode default"; got != want { t.Fatalf("header = %q, want %q", got, want) } if got, want := statusSpansText(line.Footer.Spans), "ctx ▒▒░░░░░░ 20% · 2K/10K · ↑4K ↓1K cache 0%"; got != want { From f73cb8b9ce0beeaf24e6342d650e30c7ef52e581 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 18:20:13 -0700 Subject: [PATCH 2/9] docs(acceptance): mark terminal title plan in progress Co-Authored-By: mecatl --- docs/acceptance/mecatui-terminal-title-controller.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/acceptance/mecatui-terminal-title-controller.md b/docs/acceptance/mecatui-terminal-title-controller.md index 1e4afd870f..f20ac56a54 100644 --- a/docs/acceptance/mecatui-terminal-title-controller.md +++ b/docs/acceptance/mecatui-terminal-title-controller.md @@ -4,12 +4,12 @@ **Work classification:** Architectural — replaces a third-party-owned terminal-output lifecycle and introduces a durable user-global title-template/configuration contract shared with the status-input surface. **Decision record:** [ADR 0344](../adr/0344-mecatui-terminal-title-controller.md) **Phase:** mecatui client presentation and session discoverability -**Status:** proposed, 2026-09-15. Decisions recorded with the directing operator. +**Status:** in-progress, 2026-09-16. Plan / Interface PR #1616 merged; implementation started from its approved baseline. **Delivery:** Split. The client configuration, terminal-control, compatibility, and live-run interaction contracts require separate human interface review before implementation. **Expected tasks:** deferred to orchestration after the Plan / Interface PR is approved. **Issue:** [stacklok/mecatl#1460](https://github.com/stacklok/mecatl/issues/1460); [stacklok/mecatl#1606](https://github.com/stacklok/mecatl/issues/1606). -**Plan PR:** absent until opened. -**Approved baseline:** absent until the Plan / Interface PR merges. +**Plan PR:** [#1616](https://github.com/stacklok/mecatl/pull/1616). +**Approved baseline:** `2a0c9cb11bedc6ef88503f504bbd4f94a6d31690`. Mecatui will replace Bubble Tea's `tea.View.WindowTitle` output with a UI-owned, renderer-serialized title controller. It renders one plain-text title from the same display-safe `statusline.Input` facts used by status templates, sends OSC 0 only when the rendered title changes, and clears OSC 0 on clean shutdown. This removes output-stream parsing while populating both historical title channels for terminals such as iTerm2. From 04b5e949ac35771d2faa2049fd52a120a0d446e2 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 18:39:18 -0700 Subject: [PATCH 3/9] feat(mecatui): own terminal title output Co-Authored-By: mecatl --- cmd/mecatui/main.go | 43 ++++- cmd/mecatui/terminal_title_controller.go | 101 +++++++++++ cmd/mecatui/terminal_title_controller_test.go | 161 ++++++++++++++++++ cmd/mecatui/ui/model.go | 4 + cmd/mecatui/ui/view.go | 4 +- cmd/mecatui/ui/wintitle_test.go | 24 +-- 6 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 cmd/mecatui/terminal_title_controller.go create mode 100644 cmd/mecatui/terminal_title_controller_test.go diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index ed13628b10..e89203ab4b 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -143,6 +143,25 @@ func prepareStatusSource(cfg config) (statusline.Source, error) { return buildStatusSource(customization), nil } +func prepareTerminalTitle(cfg config) (*terminalTitleController, error) { + settings, err := readClientSettings() + if err != nil { + return nil, err + } + renderer, err := newTitleRenderer(settings.TerminalTitle) + if err != nil { + return nil, fmt.Errorf("terminal_title.template: %w", err) + } + controller := newTerminalTitleController(os.Stdout, terminalTitleEnabled(cfg, settings.TerminalTitle), renderer) + controller.debug = cfg.debugTarget != "" + return controller, nil +} + +func newMecatuiProgram(ctx context.Context, deps ui.Deps, title *terminalTitleController) *tea.Program { + deps.TerminalTitle = title.Set + return tea.NewProgram(ui.New(deps), tea.WithContext(ctx), tea.WithOutput(title)) +} + func run(argv []string) error { return runWithOptions(argv, runOptions{}) } @@ -192,6 +211,11 @@ func runWithOptions(argv []string, options runOptions) error { if err != nil { return err } + title, err := prepareTerminalTitle(cfg) + if err != nil { + _ = statusSource.Close(context.Background()) + return err + } emitDebugPrivacyWarning(os.Stderr, cfg.debugTarget, cfg.debugMCP...) // UNIVERSAL global-slog floor: redirect the stdlib default to io.Discard (or, under @@ -220,7 +244,8 @@ func runWithOptions(argv []string, options runOptions) error { themeAutoDetect := resolveThemeAutoDetect(cfg, stdoutIsTTY) keyboardProbe := resolveKeyboardProbe(stdoutIsTTY) if options.recoveryOnly { - return runDisconnectedRecovery(context.Background(), argv, th, themeAutoDetect, options) + defer func() { _ = statusSource.Close(context.Background()) }() + return runDisconnectedRecovery(context.Background(), argv, th, themeAutoDetect, title, options) } // Manual two-signal handler: first signal = graceful shutdown (cancels ctx → @@ -388,9 +413,14 @@ func runWithOptions(argv []string, options runOptions) error { return err } - prog := tea.NewProgram(ui.New(deps), tea.WithContext(ctx)) + prog := newMecatuiProgram(ctx, deps, title) finalModel, runErr := prog.Run() interrupted := ctx.Err() != nil + if runErr == nil && !interrupted { + if err := title.Close(); err != nil { + runErr = err + } + } runCleanup(forceExit, func() { _ = cl.Close() @@ -541,10 +571,15 @@ func resolveKeyboardProbe(stdoutIsTTY bool) bool { return stdoutIsTTY } -func runDisconnectedRecovery(ctx context.Context, argv []string, th theme.Theme, themeAutoDetect bool, options runOptions) error { +func runDisconnectedRecovery(ctx context.Context, argv []string, th theme.Theme, themeAutoDetect bool, title *terminalTitleController, options runOptions) error { deps := ui.Deps{Ctx: ctx, Theme: th, ThemeAutoDetect: themeAutoDetect, Connect: savedConnectController{}, ConnectOpen: true, ConnectError: options.connectError, ConnectReason: options.connectReason, ConnectTarget: options.connectTarget, ConnectResumeSessionID: options.connectResumeSessionID} - prog := tea.NewProgram(ui.New(deps), tea.WithContext(ctx)) + prog := newMecatuiProgram(ctx, deps, title) finalModel, runErr := prog.Run() + if runErr == nil && ctx.Err() == nil { + if err := title.Close(); err != nil { + runErr = err + } + } if intent, ok := connectRestartIntent(finalModel); ok { return restartFromConnectIntent(argv, intent, options.connectTransport) } diff --git a/cmd/mecatui/terminal_title_controller.go b/cmd/mecatui/terminal_title_controller.go new file mode 100644 index 0000000000..b25dcf27ec --- /dev/null +++ b/cmd/mecatui/terminal_title_controller.go @@ -0,0 +1,101 @@ +package main + +import ( + "io" + "strings" + "unicode" + + "github.com/stacklok/mecatl/cmd/mecatui/statusline" +) + +const terminalTitleRunes = 160 + +// terminalTitleController owns title emission through Bubble Tea's renderer +// output writer. Set runs during View; Write is the renderer-serialized path. +type terminalTitleController struct { + output io.Writer + enabled bool + renderer *statusline.TitleRenderer + pending string + last string + wrote bool + debug bool +} + +func newTerminalTitleController(output io.Writer, enabled bool, renderer *statusline.TitleRenderer) *terminalTitleController { + return &terminalTitleController{output: output, enabled: enabled, renderer: renderer} +} + +func terminalTitleEnabled(cfg config, settings terminalTitleSettings) bool { + if cfg.terminalTitleFlagSet || cfg.terminalTitleOff { + return !cfg.terminalTitleOff + } + return settings.Enabled +} + +func (c *terminalTitleController) Set(input statusline.Input) { + if !c.enabled { + return + } + title, err := c.renderer.Render(input) + if err != nil { + return + } + if c.debug { + title = "DEBUG " + title + } + c.pending = sanitizeTerminalTitle(title) +} + +func (c *terminalTitleController) Write(p []byte) (int, error) { + if c.enabled && c.pending != c.last && (c.pending != "" || c.wrote) { + if _, err := io.WriteString(c.output, "\x1b]0;"+c.pending+"\a"); err != nil { + return 0, err + } + c.last = c.pending + if c.last != "" { + c.wrote = true + } + } + return c.output.Write(p) +} + +func (c *terminalTitleController) Close() error { + if c.enabled && c.wrote && c.last != "" { + if _, err := io.WriteString(c.output, "\x1b]0;\a"); err != nil { + return err + } + c.last = "" + } + return nil +} + +func sanitizeTerminalTitle(value string) string { + var out strings.Builder + out.Grow(len(value)) + space := true + count := 0 + for _, r := range value { + if unicode.IsSpace(r) { + space = true + continue + } + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + continue + } + if space && out.Len() > 0 { + if count == terminalTitleRunes { + break + } + out.WriteByte(' ') + count++ + } + space = false + if count == terminalTitleRunes { + break + } + out.WriteRune(r) + count++ + } + return out.String() +} diff --git a/cmd/mecatui/terminal_title_controller_test.go b/cmd/mecatui/terminal_title_controller_test.go new file mode 100644 index 0000000000..4b6121d6f1 --- /dev/null +++ b/cmd/mecatui/terminal_title_controller_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "strings" + "testing" + "unicode/utf8" + + "github.com/stacklok/mecatl/cmd/mecatui/statusline" +) + +func TestADR_0344_Scenario1_ControllerOwnsSerializedOSC0(t *testing.T) { + var output bytes.Buffer + controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}} · {{.MainAgent.State}}")) + + controller.Set(statusline.Input{Session: statusline.Session{Title: "first"}, MainAgent: statusline.MainAgent{State: "idle"}}) + if _, err := controller.Write([]byte("frame one")); err != nil { + t.Fatalf("write first frame: %v", err) + } + controller.Set(statusline.Input{Session: statusline.Session{Title: "second"}, MainAgent: statusline.MainAgent{State: "thinking"}}) + if _, err := controller.Write([]byte("frame two")); err != nil { + t.Fatalf("write second frame: %v", err) + } + + got := output.String() + if strings.Count(got, "\x1b]0;") != 2 { + t.Fatalf("OSC 0 writes = %d, want 2: %q", strings.Count(got, "\x1b]0;"), got) + } + if strings.Contains(got, "\x1b]2;") { + t.Fatalf("Bubble Tea OSC 2 reached output: %q", got) + } + if !strings.Contains(got, "\x1b]0;first · idle\a") || !strings.Contains(got, "\x1b]0;second · thinking\a") { + t.Fatalf("missing serialized OSC 0 titles: %q", got) + } +} + +func TestADR_0344_Scenario1_DeduplicatesConditionalCleanupAndDisables(t *testing.T) { + t.Run("deduplicates and clears after a title", func(t *testing.T) { + var output bytes.Buffer + controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}}")) + input := statusline.Input{Session: statusline.Session{Title: "same"}} + controller.Set(input) + _, _ = controller.Write([]byte("frame one")) + controller.Set(input) + _, _ = controller.Write([]byte("frame two")) + if err := controller.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if err := controller.Close(); err != nil { + t.Fatalf("second close: %v", err) + } + got := output.String() + if strings.Count(got, "\x1b]0;same\a") != 1 || strings.Count(got, "\x1b]0;\a") != 1 { + t.Fatalf("dedupe/cleanup output = %q", got) + } + }) + t.Run("does not clear before a title or when disabled", func(t *testing.T) { + for _, enabled := range []bool{true, false} { + var output bytes.Buffer + controller := newTerminalTitleController(&output, enabled, mustTitleRenderer(t, "{{.Session.Title}}")) + if err := controller.Close(); err != nil { + t.Fatalf("close enabled=%t: %v", enabled, err) + } + if got := output.String(); strings.Contains(got, "\x1b]0;") { + t.Fatalf("enabled=%t wrote an unexpected OSC title: %q", enabled, got) + } + } + }) +} + +func TestADR_0344_Scenario1_SanitizesRenderedTitle(t *testing.T) { + var output bytes.Buffer + controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}}")) + controller.Set(statusline.Input{Session: statusline.Session{Title: " one\x1b]2;injected\a\u007f\u0085\u2000two\u200b\nthree\t " + strings.Repeat("x", 512)}}) + _, _ = controller.Write([]byte("frame")) + + got := output.String() + if strings.Contains(got, "\x1b]2;") || strings.Contains(got, "\x1b]0;one\x1b") || strings.Contains(got, "\u200b") { + t.Fatalf("control data reached OSC construction: %q", got) + } + if !strings.Contains(got, "\x1b]0;one]2;injected twothree ") { + t.Fatalf("sanitized OSC title missing expected plain text: %q", got) + } + if !utf8.ValidString(got) || len([]rune(strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]0;"), "\aframe"))) > terminalTitleRunes { + t.Fatalf("title is invalid or unbounded: %q", got) + } +} + +func TestADR_0344_Scenario2_ExplicitDisablementPrecedence(t *testing.T) { + for _, tc := range []struct { + name, flag string + flagSet, setting, want bool + }{ + {"explicit off", "off", true, true, false}, + {"explicit on", "on", true, false, true}, + {"settings off", "on", false, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := terminalTitleEnabled(config{terminalTitle: tc.flag, terminalTitleFlagSet: tc.flagSet, terminalTitleOff: tc.flag == "off"}, terminalTitleSettings{Enabled: tc.setting}); got != tc.want { + t.Fatalf("enabled = %t, want %t", got, tc.want) + } + }) + } + t.Setenv("MECATUI_NO_TERMINAL_TITLE", "1") + cfg, err := parseFlags(nil) + if err != nil { + t.Fatalf("parse environment opt-out: %v", err) + } + if terminalTitleEnabled(cfg, terminalTitleSettings{Enabled: true}) { + t.Fatal("environment opt-out enabled title output") + } +} + +func TestADR_0344_Scenario3_TitleAndCustomHandle(t *testing.T) { + input := statusline.Input{Session: statusline.Session{Title: "Fix tests", Handle: "sess-123"}, MainAgent: statusline.MainAgent{State: "running_tool", Activity: "go test"}} + if got := renderTitle(t, "{{.Session.Title}} · {{.MainAgent.Activity}} · mecatui", input); got != "Fix tests · go test · mecatui" { + t.Fatalf("default-style title = %q", got) + } + if got := renderTitle(t, "{{.Session.Handle}}: {{.Session.Title}}", input); got != "sess-123: Fix tests" { + t.Fatalf("custom handle title = %q", got) + } +} + +func TestADR_0344_Scenario3_DebugTitle(t *testing.T) { + var output bytes.Buffer + controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}} · {{.MainAgent.State}} · mecatui")) + controller.debug = true + controller.Set(statusline.Input{Session: statusline.Session{Title: "debug target", Handle: "sess-123"}, MainAgent: statusline.MainAgent{State: "connecting"}}) + _, _ = controller.Write([]byte("frame")) + if got := output.String(); !strings.Contains(got, "\x1b]0;DEBUG debug target · connecting · mecatui\a") || strings.Contains(got, "sess-123") { + t.Fatalf("debug title = %q", got) + } +} + +func TestADR_0344_Scenario3_LocalAndRemotePresentation(t *testing.T) { + input := statusline.Input{Session: statusline.Session{Title: "shared", Handle: "sess-123"}, MainAgent: statusline.MainAgent{State: "idle"}, Workspace: statusline.Workspace{Path: "/private/workspace"}} + local := renderTitle(t, "{{.Session.Title}} · {{.MainAgent.State}}", input) + input.Server.ConnectionMode = "connect" + remote := renderTitle(t, "{{.Session.Title}} · {{.MainAgent.State}}", input) + if local != remote || strings.Contains(local, "/private/workspace") { + t.Fatalf("local=%q remote=%q; title must be connection-independent and path-free", local, remote) + } +} + +func mustTitleRenderer(t *testing.T, source string) *statusline.TitleRenderer { + t.Helper() + renderer, err := statusline.NewTitleRenderer(source) + if err != nil { + t.Fatalf("new title renderer: %v", err) + } + return renderer +} + +func renderTitle(t *testing.T, source string, input statusline.Input) string { + t.Helper() + got, err := mustTitleRenderer(t, source).Render(input) + if err != nil { + t.Fatalf("render title: %v", err) + } + return got +} diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 1b8a04d5f1..0df877b273 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -322,6 +322,10 @@ type Deps struct { // the per-phase churn is unwanted). NoWindowTitle bool + // TerminalTitle receives the display-safe snapshot during View. Its implementation + // writes only through Bubble Tea's configured output writer. + TerminalTitle func(statusline.Input) + // Debug enables every client-side diagnostic surface. DebugMouse, DebugSteer, // and DebugAsk remain narrow compatibility aliases for their original surfaces. Debug bool diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index 7adddadecf..af5f959bda 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -41,7 +41,9 @@ func (m Model) View() tea.View { var v tea.View v.KeyboardEnhancements.ReportEventTypes = true v.AltScreen = !m.deps.NoAltScreen - v.WindowTitle = m.windowTitle() + if m.deps.TerminalTitle != nil { + m.deps.TerminalTitle(m.statusLineSnapshot()) + } // Capture the mouse — but ONLY on the alt screen, and ONLY when mouse capture // is not disabled. Capturing the mouse buys wheel-scroll and the in-app // drag-select/copy layer (see selection.go) at the cost of the terminal's OWN diff --git a/cmd/mecatui/ui/wintitle_test.go b/cmd/mecatui/ui/wintitle_test.go index d9a21f0aeb..d31a37c495 100644 --- a/cmd/mecatui/ui/wintitle_test.go +++ b/cmd/mecatui/ui/wintitle_test.go @@ -361,34 +361,16 @@ func TestWindowTitleHealRefetchRoundTrip(t *testing.T) { } } -// TestWindowTitleView asserts View() wires windowTitle() into the tea.View — -// deleting view.go's v.WindowTitle assignment would silently kill the feature -// while every helper-level test stays green. Driving through New + the real -// View (not the helper directly) pins the wiring. +// TestWindowTitleView ensures the UI no longer delegates title output to Bubble Tea. func TestWindowTitleView(t *testing.T) { m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) m = applyAll(m, tea.WindowSizeMsg{Width: 120, Height: 30}, client.SessionReadyMsg{SessionID: "sess-view-0001"}, ) - // Before any prompt: the fixed session handle identifies the tab. - if got := m.View().WindowTitle; got != "sess-view-00 — mecatui" { - t.Fatalf("View().WindowTitle = %q, want the fixed session handle", got) - } - // Submit a prompt: the phase flips to running and the title leads. m = sendText(t, m, "investigate the flaky test") - if got, want := m.View().WindowTitle, "investigate the flaky test sess-view-00 — Working mecatui"; got != want { - t.Errorf("View().WindowTitle = %q, want %q after first prompt (running)", got, want) - } - // Back at idle the status word drops but the title and handle stay. - m.phase = phaseIdle - if got, want := m.View().WindowTitle, "investigate the flaky test sess-view-00 — mecatui"; got != want { - t.Errorf("View().WindowTitle = %q, want %q at idle", got, want) - } - // The opt-out pins the bare app name regardless. - m.deps.NoWindowTitle = true - if got := m.View().WindowTitle; got != "mecatui" { - t.Errorf("View().WindowTitle = %q, want bare 'mecatui' under NoWindowTitle", got) + if got := m.View().WindowTitle; got != "" { + t.Fatalf("View().WindowTitle = %q, want empty: title output belongs to the controller", got) } } From 0dd3dd52c8927d5a7a9428d8d6390b01c0fda2df Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Tue, 15 Sep 2026 18:50:16 -0700 Subject: [PATCH 4/9] feat(mecatui): expose session details during runs Co-Authored-By: mecatl --- .../ui/session_continuity_scenario5_test.go | 67 +++++++++++++++++++ cmd/mecatui/ui/sessions.go | 2 +- docs/tui.md | 24 +++++++ user-docs/mecatui/sessions.md | 10 +++ user-docs/mecatui/status-line.md | 46 +++++++++++++ 5 files changed, 148 insertions(+), 1 deletion(-) diff --git a/cmd/mecatui/ui/session_continuity_scenario5_test.go b/cmd/mecatui/ui/session_continuity_scenario5_test.go index a12415f8ed..db6ead5a1a 100644 --- a/cmd/mecatui/ui/session_continuity_scenario5_test.go +++ b/cmd/mecatui/ui/session_continuity_scenario5_test.go @@ -94,6 +94,73 @@ func TestSessionContinuityUX_Scenario5_RebindMatrix(t *testing.T) { } } +func TestADR_0344_Scenario4_SessionDetailsOpenWhileRunning(t *testing.T) { + cb := &fakeClipboard{} + m := newScenario5Model(t, cb) + m.sessionID = "full-running-session-id" + m.sessionState = "running" + m.phase = phaseRunning + + mm, cmd := m.openSessionDetails() + got := mm.(Model) + if cmd == nil { + t.Fatal("running /session should refresh the bound session details") + } + if !got.sessionDetailsOpen { + t.Fatal("running /session did not open the details overlay") + } + if got.sessionDetails().ID != m.sessionID { + t.Fatalf("details ID = %q, want full bound ID %q", got.sessionDetails().ID, m.sessionID) + } + mm, copyCmd, handled := got.onSessionDetailsKey(tea.KeyPressMsg{Code: 'c'}) + if !handled || copyCmd == nil { + t.Fatal("running /session should offer copying the full ID") + } + applyAll(mm.(Model), copyCmd()) + if len(cb.wrote) != 1 || string(cb.wrote[0]) != m.sessionID { + t.Fatalf("copied ID = %q, want full bound ID %q", cb.wrote, m.sessionID) + } +} + +func TestADR_0344_Scenario4_SessionDetailsDoNotInterruptRun(t *testing.T) { + m := newScenario5Model(t, &fakeClipboard{}) + m.sessionID = "live-session" + m.phase = phaseRunning + m.prompt.Focus() + + mm, _ := m.openSessionDetails() + m = mm.(Model) + if m.phase != phaseRunning { + t.Fatalf("opening /session changed phase to %v, want running", m.phase) + } + if m.prompt.Focused() { + t.Fatal("overlay should temporarily capture input focus") + } + + mm, _, handled := m.onSessionDetailsKey(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = mm.(Model) + if !handled || m.sessionDetailsOpen || m.phase != phaseRunning { + t.Fatalf("closing /session changed live run state: handled=%t open=%t phase=%v", handled, m.sessionDetailsOpen, m.phase) + } + if !m.prompt.Focused() { + t.Fatal("closing /session did not return focus to the live conversation") + } +} + +func TestADR_0344_Scenario4_NoSessionGuardRemains(t *testing.T) { + m := newScenario5Model(t, &fakeClipboard{}) + m.sessionID = "" + m.phase = phaseRunning + + mm, cmd := m.openSessionDetails() + m = mm.(Model) + if cmd != nil || m.sessionDetailsOpen { + t.Fatal("/session without a bound session must not open or refresh details") + } + if got := stripANSIstr(m.statusMsg); got != "no active session" { + t.Fatalf("no-session response = %q, want %q", got, "no active session") + } +} func driveSessionRebindJourney(t *testing.T, journey string, cb client.Clipboard) (Model, string) { t.Helper() m := newScenario5Model(t, cb) diff --git a/cmd/mecatui/ui/sessions.go b/cmd/mecatui/ui/sessions.go index a8e50a90ef..e227571a82 100644 --- a/cmd/mecatui/ui/sessions.go +++ b/cmd/mecatui/ui/sessions.go @@ -131,7 +131,7 @@ func (m Model) sessionCopyTarget() string { func safeSessionID(id string) string { return strconv.QuoteToASCII(id) } func (m Model) openSessionDetails() (tea.Model, tea.Cmd) { - if m.phase != phaseIdle || m.sessionID == "" { + if m.sessionID == "" { m.statusMsg = m.deps.Theme.Style("warning").Render("no active session") return m, nil } diff --git a/docs/tui.md b/docs/tui.md index ae515858ce..4b48cb84c1 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -84,6 +84,30 @@ recent 10 MiB tail, with the replacement and containing directory synced before append. Unsafe paths and failures before replacement preserve the prior log and use `io.Discard`, so diagnostics cannot corrupt the terminal. +### Terminal titles + +Mecatui also owns the terminal title through the renderer's serialized output +path. The client renders a plain-text title from the same display-safe status +input used by status templates and emits OSC 0 only when that title changes. A +clean exit clears the title after a non-empty title was emitted. The shipped +default uses the session title and activity state, falls back to `mecatui`, and +leaves the session handle out unless a custom template includes +`.Session.Handle`. + +The user-global client settings file is +`$XDG_CONFIG_HOME/mecatui/settings.yaml`. Its strict `terminal_title` section +contains `enabled` and `template`; it is independent of `status_customization`, +and command-backed status output cannot influence it. The shared `elide` +function is width-aware, while title output remains plain text rather than +StatusML. The client removes terminal controls, normalizes whitespace, and +bounds the result before OSC 0 construction. + +`--terminal-title=off` and `MECATUI_NO_TERMINAL_TITLE=1` disable title writes. +An explicit `--terminal-title=on` overrides settings disablement. Otherwise the +settings `enabled` value applies. Terminals and multiplexers decide whether to +present OSC 0, so the client cannot guarantee a tab or pane label. The complete +settings schema and precedence are in [Status line customization](https://github.com/stacklok/mecatl/blob/main/user-docs/mecatui/status-line.md#customize-the-terminal-title). + ## Build ```sh diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index c210542f06..31fafcc094 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -40,6 +40,16 @@ normal exit, `mecatui` also writes a machine-readable handoff to standard error: mecatui: final-session-id="01JOPAQUESESSIONID" ``` +## Inspect the active session during a run + +Run `/session` at any point after a session is bound, including while the agent +is responding or waiting on a tool. Mecatui opens the read-only details overlay +with the full session ID. Press `c` to copy that exact ID, then press `esc` to +close the overlay and return focus to the conversation. Opening the overlay +does not cancel, pause, or steer the run. + +When no session is bound, `/session` keeps the `no active session` response. + ## Browse and maintain stored sessions Open the session inventory without creating a session: diff --git a/user-docs/mecatui/status-line.md b/user-docs/mecatui/status-line.md index 34ca14ef36..189824b241 100644 --- a/user-docs/mecatui/status-line.md +++ b/user-docs/mecatui/status-line.md @@ -316,6 +316,52 @@ For the lower-level client architecture and the complete source lifecycle, see the [status-line section in `docs/tui.md`](https://github.com/stacklok/mecatl/blob/main/docs/tui.md#local-status-lines). +## Customize the terminal title + +`mecatui` owns the terminal title and sends it through the same serialized output +path as the interface. It emits OSC 0 when the rendered title changes and clears +it on a clean exit. The title is plain text, so StatusML tags and command output +never become part of it. + +Configure the title in the client-owned +`$XDG_CONFIG_HOME/mecatui/settings.yaml` file (normally +`~/.config/mecatui/settings.yaml`): + +```yaml +terminal_title: + enabled: true + template: '{{if .Session.Title}}{{.Session.Title}} · {{.MainAgent.State}} · mecatui{{else}}mecatui{{end}}' +``` + +The setting applies to embedded and connected clients. The shipped template +uses the session title and agent state, and falls back to `mecatui` before a +session title exists. It does not include the session handle. Add +`.Session.Handle` when you want a handle in the title. The title template gets +the same display-safe input as status templates, including `Session`, `Model`, +`Context`, `Usage`, `Workspace`, `Terminal`, `MainAgent`, `Delegation`, and +`Clock` values. It also supports `elide WIDTH VALUE`: a non-positive width is +empty, a fitting value is unchanged, width `1` is `…`, and wider values are +truncated to the widest prefix that fits plus `…`. + +Title writes follow this precedence: + +1. `--terminal-title=off` disables the controller, while + `--terminal-title=on` enables it even when settings disable it. Both forms + accept `true`, `false`, `1`, and `0`. +2. When the flag is absent, `MECATUI_NO_TERMINAL_TITLE=1` or `true` disables + title writes. +3. When neither explicit control applies, `terminal_title.enabled` controls + the feature. The default is enabled. + +The renderer removes terminal controls, collapses whitespace to single spaces, +and bounds the title before constructing OSC 0. A terminal emulator or +multiplexer decides whether and where to show OSC 0, so a tab or pane label can +remain unchanged even when `mecatui` emits a title. Disable titles when the +terminal environment owns title presentation or filters OSC sequences. Invalid +YAML, unknown fields, and invalid title templates stop startup with an error +that identifies `terminal_title` or `terminal_title.template`. Restart +`mecatui` after changing this file; settings are not hot-reloaded. + ## Next steps - [Choose a theme](./themes.md) for the rest of the interface. From 8af47633a4146e2423886bcabab1c99f6f63adf3 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Wed, 16 Sep 2026 18:01:08 -0700 Subject: [PATCH 5/9] docs(acceptance): mark terminal title plan landed Co-Authored-By: mecatl --- docs/acceptance/mecatui-terminal-title-controller.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/mecatui-terminal-title-controller.md b/docs/acceptance/mecatui-terminal-title-controller.md index f20ac56a54..1b0d7509a1 100644 --- a/docs/acceptance/mecatui-terminal-title-controller.md +++ b/docs/acceptance/mecatui-terminal-title-controller.md @@ -4,7 +4,7 @@ **Work classification:** Architectural — replaces a third-party-owned terminal-output lifecycle and introduces a durable user-global title-template/configuration contract shared with the status-input surface. **Decision record:** [ADR 0344](../adr/0344-mecatui-terminal-title-controller.md) **Phase:** mecatui client presentation and session discoverability -**Status:** in-progress, 2026-09-16. Plan / Interface PR #1616 merged; implementation started from its approved baseline. +**Status:** landed, 2026-09-16. Implementation candidate completed from the approved Plan / Interface baseline; authoritative when its implementation PR merges. **Delivery:** Split. The client configuration, terminal-control, compatibility, and live-run interaction contracts require separate human interface review before implementation. **Expected tasks:** deferred to orchestration after the Plan / Interface PR is approved. **Issue:** [stacklok/mecatl#1460](https://github.com/stacklok/mecatl/issues/1460); [stacklok/mecatl#1606](https://github.com/stacklok/mecatl/issues/1606). From cc6957733b4751ff09caa3883deae5136ff30298 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Wed, 16 Sep 2026 18:37:58 -0700 Subject: [PATCH 6/9] fix(mecatui): harden terminal title controller Co-Authored-By: mecatl --- cmd/mecatui/client_settings.go | 7 +- cmd/mecatui/client_settings_test.go | 40 +- cmd/mecatui/config.go | 15 +- cmd/mecatui/main.go | 40 +- cmd/mecatui/statusline/title.go | 18 +- cmd/mecatui/terminal_title_controller.go | 32 +- cmd/mecatui/terminal_title_controller_test.go | 99 ++++- cmd/mecatui/ui/debug_session_test.go | 11 - cmd/mecatui/ui/model.go | 8 - .../ui/predictable_session_handles_test.go | 9 +- cmd/mecatui/ui/title_revision_test.go | 8 + cmd/mecatui/ui/wintitle.go | 111 ----- cmd/mecatui/ui/wintitle_test.go | 384 ------------------ docs/tui.md | 57 +-- user-docs/mecatui/status-line.md | 4 +- 15 files changed, 204 insertions(+), 639 deletions(-) delete mode 100644 cmd/mecatui/ui/wintitle.go delete mode 100644 cmd/mecatui/ui/wintitle_test.go diff --git a/cmd/mecatui/client_settings.go b/cmd/mecatui/client_settings.go index a394349de9..70130c4765 100644 --- a/cmd/mecatui/client_settings.go +++ b/cmd/mecatui/client_settings.go @@ -387,15 +387,12 @@ func mergeKeymaps(a, b map[string][]string) map[string][]string { // // The merged map then goes through keymap.Parse + keymap.Validate unchanged: // an invalid override still fails startup. -func applyKeyOverridesToDeps(cfg config, deps *ui.Deps) error { +func applyKeyOverridesToDeps(cfg config, settings clientSettings, deps *ui.Deps) error { legacyMap, legacySet, err := readLegacyKeymap() if err != nil { return err } - clientMap, _, err := readClientKeymap() - if err != nil { - return err - } + clientMap := splitKeymap(settings.Keymap) cliMap := keyOverridesFromConfig(cfg) merged := mergeKeymaps(mergeKeymaps(legacyMap, clientMap), cliMap) if legacySet { diff --git a/cmd/mecatui/client_settings_test.go b/cmd/mecatui/client_settings_test.go index 264942ca51..4f0fb13548 100644 --- a/cmd/mecatui/client_settings_test.go +++ b/cmd/mecatui/client_settings_test.go @@ -21,6 +21,15 @@ import ( "github.com/stacklok/mecatl/internal/cliconfig" ) +func mustReadClientSettings(t *testing.T) clientSettings { + t.Helper() + settings, err := readClientSettings() + if err != nil { + t.Fatalf("read client settings: %v", err) + } + return settings +} + // writeSettings writes body to //settings.yaml under the // test's temp XDG root, creating the app dir. func writeSettings(t *testing.T, app, body string) string { @@ -279,7 +288,7 @@ func TestKeymapPrecedenceCLIBeatsClientBeatsLegacy(t *testing.T) { writeSettings(t, "mecatui", "keymap:\n Agents: ctrl+f3\n ExpandTools: ctrl+f4\n") cfg := config{keymap: &cliconfig.KeyValueList{"Agents": "ctrl+f5"}} var deps ui.Deps - if err := applyKeyOverridesToDeps(cfg, &deps); err != nil { + if err := applyKeyOverridesToDeps(cfg, mustReadClientSettings(t), &deps); err != nil { t.Fatalf("apply: %v", err) } want := map[string][]string{ @@ -297,7 +306,7 @@ func TestKeymapPrecedenceClientBeatsLegacyPerAction(t *testing.T) { writeSettings(t, "mecatl", "keymap:\n Agents: ctrl+f1\n Effort: ctrl+f2\n") writeSettings(t, "mecatui", "keymap:\n Agents: ctrl+f3\n") var deps ui.Deps - if err := applyKeyOverridesToDeps(config{}, &deps); err != nil { + if err := applyKeyOverridesToDeps(config{}, mustReadClientSettings(t), &deps); err != nil { t.Fatalf("apply: %v", err) } want := map[string][]string{ @@ -318,7 +327,7 @@ func TestKeymapLegacyOnlyBackCompat(t *testing.T) { writeSettings(t, "mecatl", "keymap:\n Agents: ctrl+f1\n Effort: ctrl+f2\n") cfg := config{keymap: &cliconfig.KeyValueList{"Agents": "ctrl+f5"}} var deps ui.Deps - if err := applyKeyOverridesToDeps(cfg, &deps); err != nil { + if err := applyKeyOverridesToDeps(cfg, mustReadClientSettings(t), &deps); err != nil { t.Fatalf("apply: %v", err) } want := map[string][]string{ @@ -335,7 +344,7 @@ func TestKeymapDeprecationWarnFiresOnceOnLegacyKeymap(t *testing.T) { writeSettings(t, "mecatl", "keymap:\n Agents: ctrl+f1\n") var deps ui.Deps out := captureStderr(t, func() { - if err := applyKeyOverridesToDeps(config{}, &deps); err != nil { + if err := applyKeyOverridesToDeps(config{}, mustReadClientSettings(t), &deps); err != nil { t.Fatalf("apply: %v", err) } }) @@ -351,7 +360,7 @@ func TestCanonicalDebugPrintsKeymapDiagnostics(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var deps ui.Deps out := captureStderr(t, func() { - if err := applyKeyOverridesToDeps(config{debugKeymap: true}, &deps); err != nil { + if err := applyKeyOverridesToDeps(config{debugKeymap: true}, mustReadClientSettings(t), &deps); err != nil { t.Fatalf("apply: %v", err) } }) @@ -369,7 +378,7 @@ func TestKeymapDeprecationWarnSilentWithoutLegacyKeymap(t *testing.T) { writeSettings(t, "mecatui", "keymap:\n Agents: ctrl+f3\n") var deps ui.Deps out := captureStderr(t, func() { - if err := applyKeyOverridesToDeps(config{}, &deps); err != nil { + if err := applyKeyOverridesToDeps(config{}, mustReadClientSettings(t), &deps); err != nil { t.Fatalf("apply: %v", err) } }) @@ -386,7 +395,7 @@ func TestApplyKeyOverridesInvalidActionStillFailsStartup(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) writeSettings(t, "mecatui", "keymap:\n NotAnAction: ctrl+f9\n") var deps ui.Deps - err := applyKeyOverridesToDeps(config{}, &deps) + err := applyKeyOverridesToDeps(config{}, mustReadClientSettings(t), &deps) if err == nil { t.Fatal("an unknown action name must surface as an error") } @@ -597,6 +606,23 @@ func TestADR_0344_Scenario2_InvalidConfigurationFailsActionably(t *testing.T) { } }) } + + t.Run("data-dependent runtime failure is safe and actionable", func(t *testing.T) { + renderer, err := statusline.NewTitleRenderer("{{if .Session.Title}}{{index .Session.Title 99}}{{else}}mecatui{{end}}") + if err != nil { + t.Fatalf("build conditionally valid title renderer: %v", err) + } + var output bytes.Buffer + controller := newTerminalTitleController(&output, true, renderer) + controller.Set(statusline.Input{Session: statusline.Session{Title: "short"}}) + _, err = controller.Write([]byte("frame")) + if err == nil || !strings.Contains(err.Error(), "terminal_title.template") || !strings.Contains(err.Error(), "index out of range") { + t.Fatalf("runtime title write error = %v, want field and actionable cause", err) + } + if output.Len() != 0 { + t.Fatalf("failed runtime title wrote output: %q", output.String()) + } + }) } func TestADR_0344_Scenario2_CommandStatusCannotControlTitle(t *testing.T) { diff --git a/cmd/mecatui/config.go b/cmd/mecatui/config.go index a9527b3477..93f4a33ec5 100644 --- a/cmd/mecatui/config.go +++ b/cmd/mecatui/config.go @@ -86,12 +86,9 @@ type config struct { // inline). Wired to ui.Deps.NoMouse. noMouse bool - // terminalTitleOff suppresses the dynamic terminal window/tab title (leaving - // it at the bare "mecatui"). Off by default (the title is dynamic: " — - // <status word> mecatui"). Honoured from --terminal-title=off/false/0 or - // MECATUI_NO_TERMINAL_TITLE=1/true. The escape hatch for terminals/ - // multiplexers where a set title does more harm than good. Wired to - // ui.Deps.NoWindowTitle. + // terminalTitleOff suppresses the terminal-title controller's OSC writes. + // Off by default. Honoured from --terminal-title=off/false/0 or + // MECATUI_NO_TERMINAL_TITLE=1/true. terminalTitle string terminalTitleOff bool terminalTitleFlagSet bool @@ -401,9 +398,9 @@ func parseTransportFlags(mode transportMode, out io.Writer, args []string, brows fs.BoolVar(&cfg.debug, "debug", false, "enable client diagnostics and debug-only commands") fs.BoolVar(&cfg.noAltScreen, "no-alt-screen", false, "render inline in the terminal's normal buffer instead of the alternate screen, preserving native scrollback/search") fs.BoolVar(&cfg.noAltScreen, "inline", false, "alias for --no-alt-screen: render inline in the normal buffer, preserving native scrollback/search") - fs.BoolVar(&cfg.noMouse, "no-mouse", false, "disable in-app mouse handling and use the terminal's native text selection; keyboard scrolling remains available") - fs.BoolVar(&cfg.noBanner, "no-banner", false, "hide the welcome illustration; prompt hints remain visible") - fs.StringVar(&cfg.terminalTitle, "terminal-title", "on", "update the terminal title with session status: on or off (also true/false/1/0)") + fs.BoolVar(&cfg.noMouse, "no-mouse", false, "disable mouse capture on the alt screen so the terminal's NATIVE click-drag selection works (for tmux/zellij/web terminals that strip OSC52, or when you prefer native select); trades away in-app mouse-wheel scroll and the in-app drag-select/copy layer. Keyboard scroll (pgup/pgdn/home/end) is unaffected. Or set MECATUI_NO_MOUSE=1") + fs.BoolVar(&cfg.noBanner, "no-banner", false, "disable the welcome splash (mascot + gradient wordmark); the plain prompt hint and affordance list are still shown. Also forced on under --quiet or a non-interactive stdin") + fs.StringVar(&cfg.terminalTitle, "terminal-title", "on", "terminal title controller: on enables the configured/default plain-text OSC 0 title; off emits no title or cleanup sequence. Accepts on/off/true/false/1/0. Or set MECATUI_NO_TERMINAL_TITLE=1") // Keymap overrides: action=chords (comma-separated), repeatable. cfg.keymap = new(cliconfig.KeyValueList) diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index e89203ab4b..2f9c4f70cc 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -132,29 +132,20 @@ func buildStatusSource(customization statusCustomization) statusline.Source { return newSource(customization) } -func prepareStatusSource(cfg config) (statusline.Source, error) { - if err := validateRunConfig(cfg); err != nil { - return nil, err - } - customization, err := readStatusCustomization() - if err != nil { - return nil, err - } - return buildStatusSource(customization), nil -} - -func prepareTerminalTitle(cfg config) (*terminalTitleController, error) { - settings, err := readClientSettings() - if err != nil { - return nil, err +// buildClientPresentation constructs status and title rendering from one validated +// client-settings snapshot. Both embedded and connect modes use this same path. +func buildClientPresentation(cfg config, settings clientSettings, output io.Writer) (statusline.Source, *terminalTitleController, error) { + customization := shippedStatusCustomization() + if settings.StatusCustomization != nil { + customization = *settings.StatusCustomization } renderer, err := newTitleRenderer(settings.TerminalTitle) if err != nil { - return nil, fmt.Errorf("terminal_title.template: %w", err) + return nil, nil, fmt.Errorf("terminal_title.template: %w", err) } - controller := newTerminalTitleController(os.Stdout, terminalTitleEnabled(cfg, settings.TerminalTitle), renderer) + controller := newTerminalTitleController(output, terminalTitleEnabled(cfg, settings.TerminalTitle), renderer) controller.debug = cfg.debugTarget != "" - return controller, nil + return buildStatusSource(customization), controller, nil } func newMecatuiProgram(ctx context.Context, deps ui.Deps, title *terminalTitleController) *tea.Program { @@ -207,13 +198,15 @@ func runWithOptions(argv []string, options runOptions) error { if cfg.providerKeys.AuthFileWarning != "" { fmt.Fprintln(os.Stderr, "mecatui: WARNING: "+wrapAuthFileWarning(cfg.providerKeys.AuthFileWarning)) } - statusSource, err := prepareStatusSource(cfg) + if err := validateRunConfig(cfg); err != nil { + return err + } + settings, err := readClientSettings() if err != nil { return err } - title, err := prepareTerminalTitle(cfg) + statusSource, title, err := buildClientPresentation(cfg, settings, os.Stdout) if err != nil { - _ = statusSource.Close(context.Background()) return err } emitDebugPrivacyWarning(os.Stderr, cfg.debugTarget, cfg.debugMCP...) @@ -389,9 +382,6 @@ func runWithOptions(argv []string, options runOptions) error { // Escape hatch: disable mouse capture so the terminal's native selection // works (trades away in-app wheel scroll + drag-select). Default false. NoMouse: cfg.noMouse, - // Dynamic terminal window/tab title: off collapses to bare "mecatui". - // Default false (dynamic: "<title> — <status word> mecatui"). - NoWindowTitle: cfg.terminalTitleOff, // Seed prompt from -p/--prompt + --prompt-file: joined at startup and // auto-submitted once the first session is ready (interactive-seed, NOT a // one-shot — the TUI stays open for follow-ups). Empty = no seed. @@ -407,7 +397,7 @@ func runWithOptions(argv []string, options runOptions) error { deps.OpenURL = openBrowserURL // Apply keymap overrides (CLI for now). - if err := applyKeyOverridesToDeps(cfg, &deps); err != nil { + if err := applyKeyOverridesToDeps(cfg, settings, &deps); err != nil { _ = cl.Close() transCleanup() return err diff --git a/cmd/mecatui/statusline/title.go b/cmd/mecatui/statusline/title.go index db99ef2134..0819ab07d7 100644 --- a/cmd/mecatui/statusline/title.go +++ b/cmd/mecatui/statusline/title.go @@ -12,6 +12,20 @@ import ( // It deliberately has no StatusML parsing or command-source integration. type TitleRenderer struct{ template *template.Template } +type titleTemplateInput struct { + templateInput + Workspace titleTemplateWorkspace +} + +type titleTemplateWorkspace struct{ Location, Name templateText } + +func newTitleTemplateInput(input Input) titleTemplateInput { + return titleTemplateInput{ + templateInput: newTemplateInput(input), + Workspace: titleTemplateWorkspace{escapeTemplateText(input.Workspace.Location), escapeTemplateText(input.Workspace.Name)}, + } +} + // NewTitleRenderer validates and compiles a title template. The startup render // catches template expressions which parse successfully but cannot execute // against the status projection. @@ -21,7 +35,7 @@ func NewTitleRenderer(source string) (*TitleRenderer, error) { return nil, err } var output strings.Builder - if err := t.Execute(&output, newTemplateInput(Input{})); err != nil { + if err := t.Execute(&output, newTitleTemplateInput(Input{})); err != nil { return nil, err } return &TitleRenderer{template: t}, nil @@ -31,7 +45,7 @@ func NewTitleRenderer(source string) (*TitleRenderer, error) { // controller owns final terminal-control sanitization and bounds. func (r *TitleRenderer) Render(input Input) (string, error) { var output strings.Builder - if err := r.template.Execute(&output, newTemplateInput(input)); err != nil { + if err := r.template.Execute(&output, newTitleTemplateInput(input)); err != nil { return "", err } return html.UnescapeString(output.String()), nil diff --git a/cmd/mecatui/terminal_title_controller.go b/cmd/mecatui/terminal_title_controller.go index b25dcf27ec..f6d96b8fa8 100644 --- a/cmd/mecatui/terminal_title_controller.go +++ b/cmd/mecatui/terminal_title_controller.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "io" "strings" + "sync" "unicode" "github.com/stacklok/mecatl/cmd/mecatui/statusline" @@ -13,13 +15,15 @@ const terminalTitleRunes = 160 // terminalTitleController owns title emission through Bubble Tea's renderer // output writer. Set runs during View; Write is the renderer-serialized path. type terminalTitleController struct { - output io.Writer - enabled bool - renderer *statusline.TitleRenderer - pending string - last string - wrote bool - debug bool + mu sync.Mutex + output io.Writer + enabled bool + renderer *statusline.TitleRenderer + pending string + last string + wrote bool + debug bool + renderErr error } func newTerminalTitleController(output io.Writer, enabled bool, renderer *statusline.TitleRenderer) *terminalTitleController { @@ -34,11 +38,14 @@ func terminalTitleEnabled(cfg config, settings terminalTitleSettings) bool { } func (c *terminalTitleController) Set(input statusline.Input) { - if !c.enabled { + c.mu.Lock() + defer c.mu.Unlock() + if !c.enabled || c.renderErr != nil { return } title, err := c.renderer.Render(input) if err != nil { + c.renderErr = fmt.Errorf("terminal_title.template: execute: %w", err) return } if c.debug { @@ -48,6 +55,11 @@ func (c *terminalTitleController) Set(input statusline.Input) { } func (c *terminalTitleController) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.renderErr != nil { + return 0, c.renderErr + } if c.enabled && c.pending != c.last && (c.pending != "" || c.wrote) { if _, err := io.WriteString(c.output, "\x1b]0;"+c.pending+"\a"); err != nil { return 0, err @@ -61,13 +73,15 @@ func (c *terminalTitleController) Write(p []byte) (int, error) { } func (c *terminalTitleController) Close() error { + c.mu.Lock() + defer c.mu.Unlock() if c.enabled && c.wrote && c.last != "" { if _, err := io.WriteString(c.output, "\x1b]0;\a"); err != nil { return err } c.last = "" } - return nil + return c.renderErr } func sanitizeTerminalTitle(value string) string { diff --git a/cmd/mecatui/terminal_title_controller_test.go b/cmd/mecatui/terminal_title_controller_test.go index 4b6121d6f1..219ef865f5 100644 --- a/cmd/mecatui/terminal_title_controller_test.go +++ b/cmd/mecatui/terminal_title_controller_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "strings" + "sync" "testing" "unicode/utf8" @@ -10,7 +11,7 @@ import ( ) func TestADR_0344_Scenario1_ControllerOwnsSerializedOSC0(t *testing.T) { - var output bytes.Buffer + var output lockedBuffer controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}} · {{.MainAgent.State}}")) controller.Set(statusline.Input{Session: statusline.Session{Title: "first"}, MainAgent: statusline.MainAgent{State: "idle"}}) @@ -22,9 +23,25 @@ func TestADR_0344_Scenario1_ControllerOwnsSerializedOSC0(t *testing.T) { t.Fatalf("write second frame: %v", err) } + var wg sync.WaitGroup + for range 32 { + wg.Add(2) + go func() { + defer wg.Done() + controller.Set(statusline.Input{Session: statusline.Session{Title: "concurrent"}, MainAgent: statusline.MainAgent{State: "thinking"}}) + }() + go func() { + defer wg.Done() + if _, err := controller.Write([]byte("frame")); err != nil { + t.Errorf("write concurrent frame: %v", err) + } + }() + } + wg.Wait() + got := output.String() - if strings.Count(got, "\x1b]0;") != 2 { - t.Fatalf("OSC 0 writes = %d, want 2: %q", strings.Count(got, "\x1b]0;"), got) + if strings.Count(got, "\x1b]0;") < 2 { + t.Fatalf("OSC 0 writes = %d, want at least 2: %q", strings.Count(got, "\x1b]0;"), got) } if strings.Contains(got, "\x1b]2;") { t.Fatalf("Bubble Tea OSC 2 reached output: %q", got) @@ -34,6 +51,23 @@ func TestADR_0344_Scenario1_ControllerOwnsSerializedOSC0(t *testing.T) { } } +type lockedBuffer struct { + mu sync.Mutex + bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.Buffer.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.Buffer.String() +} + func TestADR_0344_Scenario1_DeduplicatesConditionalCleanupAndDisables(t *testing.T) { t.Run("deduplicates and clears after a title", func(t *testing.T) { var output bytes.Buffer @@ -54,15 +88,23 @@ func TestADR_0344_Scenario1_DeduplicatesConditionalCleanupAndDisables(t *testing t.Fatalf("dedupe/cleanup output = %q", got) } }) - t.Run("does not clear before a title or when disabled", func(t *testing.T) { + t.Run("does not clear before a title or emit when disabled", func(t *testing.T) { for _, enabled := range []bool{true, false} { var output bytes.Buffer controller := newTerminalTitleController(&output, enabled, mustTitleRenderer(t, "{{.Session.Title}}")) + controller.Set(statusline.Input{Session: statusline.Session{Title: "must-not-emit-when-disabled"}}) + if _, err := controller.Write([]byte("frame")); err != nil { + t.Fatalf("write enabled=%t: %v", enabled, err) + } if err := controller.Close(); err != nil { t.Fatalf("close enabled=%t: %v", enabled, err) } - if got := output.String(); strings.Contains(got, "\x1b]0;") { - t.Fatalf("enabled=%t wrote an unexpected OSC title: %q", enabled, got) + got := output.String() + if !enabled && strings.Contains(got, "\x1b]0;") { + t.Fatalf("disabled controller wrote an OSC title: %q", got) + } + if enabled && strings.Count(got, "\x1b]0;") != 2 { + t.Fatalf("enabled controller title and cleanup count = %d, want 2: %q", strings.Count(got, "\x1b]0;"), got) } } }) @@ -96,8 +138,21 @@ func TestADR_0344_Scenario2_ExplicitDisablementPrecedence(t *testing.T) { {"settings off", "on", false, false, false}, } { t.Run(tc.name, func(t *testing.T) { - if got := terminalTitleEnabled(config{terminalTitle: tc.flag, terminalTitleFlagSet: tc.flagSet, terminalTitleOff: tc.flag == "off"}, terminalTitleSettings{Enabled: tc.setting}); got != tc.want { - t.Fatalf("enabled = %t, want %t", got, tc.want) + cfg := config{terminalTitle: tc.flag, terminalTitleFlagSet: tc.flagSet, terminalTitleOff: tc.flag == "off"} + settings := defaultClientSettings() + settings.TerminalTitle = terminalTitleSettings{Enabled: tc.setting, Template: "configured"} + var output bytes.Buffer + status, title, err := buildClientPresentation(cfg, settings, &output) + if err != nil { + t.Fatalf("build client presentation: %v", err) + } + t.Cleanup(func() { _ = status.Close(t.Context()) }) + title.Set(statusline.Input{}) + if _, err := title.Write([]byte("frame")); err != nil { + t.Fatalf("write frame: %v", err) + } + if got := strings.Contains(output.String(), "\x1b]0;configured\a"); got != tc.want { + t.Fatalf("configured title emitted = %t, want %t: %q", got, tc.want, output.String()) } }) } @@ -133,12 +188,30 @@ func TestADR_0344_Scenario3_DebugTitle(t *testing.T) { } func TestADR_0344_Scenario3_LocalAndRemotePresentation(t *testing.T) { + settings := defaultClientSettings() + settings.TerminalTitle.Template = "{{.Session.Title}} · {{.MainAgent.State}}" input := statusline.Input{Session: statusline.Session{Title: "shared", Handle: "sess-123"}, MainAgent: statusline.MainAgent{State: "idle"}, Workspace: statusline.Workspace{Path: "/private/workspace"}} - local := renderTitle(t, "{{.Session.Title}} · {{.MainAgent.State}}", input) - input.Server.ConnectionMode = "connect" - remote := renderTitle(t, "{{.Session.Title}} · {{.MainAgent.State}}", input) - if local != remote || strings.Contains(local, "/private/workspace") { - t.Fatalf("local=%q remote=%q; title must be connection-independent and path-free", local, remote) + + outputs := make([]string, 0, 2) + for _, mode := range []string{"embedded", "connect"} { + var output bytes.Buffer + status, title, err := buildClientPresentation(config{}, settings, &output) + if err != nil { + t.Fatalf("build %s presentation: %v", mode, err) + } + t.Cleanup(func() { _ = status.Close(t.Context()) }) + input.Server.ConnectionMode = mode + title.Set(input) + if _, err := title.Write([]byte("frame")); err != nil { + t.Fatalf("write %s presentation: %v", mode, err) + } + outputs = append(outputs, output.String()) + } + if outputs[0] != outputs[1] || strings.Contains(outputs[0], "/private/workspace") { + t.Fatalf("embedded=%q connect=%q; title must be connection-independent and path-free", outputs[0], outputs[1]) + } + if _, err := statusline.NewTitleRenderer("{{.Workspace.Path}}"); err == nil || !strings.Contains(err.Error(), "Path") { + t.Fatalf("title Workspace.Path projection error = %v, want unavailable-field error", err) } } diff --git a/cmd/mecatui/ui/debug_session_test.go b/cmd/mecatui/ui/debug_session_test.go index 63b025752b..2e5cbcc1e8 100644 --- a/cmd/mecatui/ui/debug_session_test.go +++ b/cmd/mecatui/ui/debug_session_test.go @@ -121,17 +121,6 @@ func TestDebugSessionDetailsShowAndCopyExactTargetID(t *testing.T) { } } -func TestDebugWindowTitleStartsWithStableHandleAcrossPhases(t *testing.T) { - m := debugUIModel("target-session", 80) - prefix := "DEBUG " + client.SessionHandle("target-session") - for _, p := range []phase{phaseConnecting, phaseIdle, phaseRunning, phaseAwaitingApproval, phaseFatal} { - m.phase = p - if got := m.windowTitle(); !strings.HasPrefix(got, prefix) { - t.Fatalf("phase %v title = %q", p, got) - } - } -} - func TestDebugSessionDisablesWorkspaceEnrollment(t *testing.T) { control := &workspaceEnrollmentControlFake{} m, send := builtinDispatchModel(t, client.Capabilities{WorkspaceEnrollment: true}, false) diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 0df877b273..042518169e 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -314,14 +314,6 @@ type Deps struct { // that strip OSC52 or users who prefer native selection. NoMouse bool - // NoWindowTitle suppresses the dynamic terminal window/tab title, leaving the - // title at the bare "mecatui" (no phase word, no session title). Default - // false (the title is dynamic: "<title> — <status word> mecatui"). Set true by - // --terminal-title=off / MECATUI_NO_TERMINAL_TITLE=1 — the escape hatch for - // terminals/multiplexers where a set title does more harm than good (or where - // the per-phase churn is unwanted). - NoWindowTitle bool - // TerminalTitle receives the display-safe snapshot during View. Its implementation // writes only through Bubble Tea's configured output writer. TerminalTitle func(statusline.Input) diff --git a/cmd/mecatui/ui/predictable_session_handles_test.go b/cmd/mecatui/ui/predictable_session_handles_test.go index b85f906d60..2fc70073a0 100644 --- a/cmd/mecatui/ui/predictable_session_handles_test.go +++ b/cmd/mecatui/ui/predictable_session_handles_test.go @@ -26,16 +26,10 @@ func TestPredictableSessionHandles_Scenario1_SharedNormalHandle(t *testing.T) { if got := stripANSIstr(m.renderHeader()); !strings.Contains(got, "session "+want) || strings.Contains(got, "#"+want) { t.Fatalf("header does not use bare handle %q: %q", want, got) } - if got := m.windowTitle(); !strings.Contains(got, " "+want+" — ") || strings.Contains(got, "#"+want) { - t.Fatalf("ordinary window title = %q, want bare handle %q", got, want) - } m.deps.DebugTarget = id if got := stripANSIstr(m.renderHeader()); !strings.Contains(got, "DEBUG target "+want) || strings.Contains(got, "#"+want) { t.Fatalf("debugger target chrome does not use bare handle %q: %q", want, got) } - if got := m.windowTitle(); !strings.HasPrefix(got, "DEBUG "+want+" — ") { - t.Fatalf("debug window title = %q, want debugger handle %q", got, want) - } st := newSessionsPanelState() st.loading = false @@ -131,8 +125,7 @@ func testPredictableSessionHandle(t *testing.T, checks predictableSessionHandleC m.sessionID = id m.sessionTitle = "debug" presentations := map[string]string{ - "header": stripANSIstr(m.renderHeader()), - "debugger title": m.windowTitle(), + "header": stripANSIstr(m.renderHeader()), } st := newSessionsPanelState() st.loading, st.loadState = false, sessionsComplete diff --git a/cmd/mecatui/ui/title_revision_test.go b/cmd/mecatui/ui/title_revision_test.go index 3e179f1a57..7f9ac69516 100644 --- a/cmd/mecatui/ui/title_revision_test.go +++ b/cmd/mecatui/ui/title_revision_test.go @@ -4,8 +4,16 @@ import ( "testing" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" ) +func TestViewLeavesWindowTitleEmpty(t *testing.T) { + m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) + if got := m.View().WindowTitle; got != "" { + t.Fatalf("View().WindowTitle = %q, want empty: title output belongs to the controller", got) + } +} + func TestTitleRevisionSnapshotRejectsDelayedLiveEvent(t *testing.T) { m := titleModel(t, &titleRenamer{}) m, _, _ = m.onResolvedModelMsg(client.ResolvedModelMsg{ diff --git a/cmd/mecatui/ui/wintitle.go b/cmd/mecatui/ui/wintitle.go deleted file mode 100644 index 7fac6c0a93..0000000000 --- a/cmd/mecatui/ui/wintitle.go +++ /dev/null @@ -1,111 +0,0 @@ -package ui - -import ( - "strings" - - "github.com/stacklok/mecatl/cmd/mecatui/client" -) - -// windowTitleRunes caps the title SEGMENT of the terminal window/tab title (the -// "<title> — …" head). 40 runes is tab-sized: wide enough for a real prompt's -// first clause, narrow enough that the status word + "mecatui" survive a -// truncated tab bar (tab bars truncate from the right, so the title leads and -// the status word trails). Mirrors session.ClampTitle's tab-friendly contract -// at a tighter bound (session titles are 120 runes; a tab title is much less). -const windowTitleRunes = 40 - -// windowTitle computes the terminal window/tab title for the current phase and -// session. The format is title-first because terminal tab bars truncate from -// the RIGHT, so the title (the most identifying thing) leads and the status -// word + "mecatui" trail: -// -// <title> <handle> — Working mecatui (phaseRunning) -// <title> <handle> — ⚠ mecatui (phaseAwaitingApproval) -// <title> <handle> — Connecting mecatui (phaseConnecting) -// <title> <handle> — ✗ mecatui (phaseFatal) -// <title> <handle> — mecatui (phaseIdle / phaseReplay) -// <handle> — mecatui (no title yet) -// mecatui (no session yet, or opt-out) -// -// The status is a STATIC WORD, never an animated spinner: per-frame title churn -// trips OS attention heuristics (the dock bounces / the taskbar flashes on every -// title change), so a word that changes only on a phase transition is the -// correct granularity. An empty/whitespace title falls back to the bare -// "mecatui" so a fresh session reads as just the app name. NoWindowTitle -// (composition: --terminal-title=off / MECATUI_NO_TERMINAL_TITLE=1) collapses -// the whole thing to "mecatui" — the escape hatch for terminals/multiplexers -// where a set title does more harm than good. -func (m Model) windowTitle() string { - if m.deps.NoWindowTitle { - return "mecatui" - } - if m.deps.DebugTarget != "" { - return "DEBUG " + client.SessionHandle(m.deps.DebugTarget) + " — " + debugPhaseTitle(m.phase) + " mecatui" - } - title := clampWindowTitle(m.sessionTitle) - handle := client.SessionHandle(m.sessionID) - switch { - case title != "" && handle != "": - suffix := " " + handle - title = truncate(title, windowTitleRunes-len([]rune(suffix))) + suffix - case handle != "": - title = handle - } - status := phaseStatusWord(m.phase) - switch { - case title == "" && status == "": - return "mecatui" - case title == "": - return status + " mecatui" - case status == "": - return title + " — mecatui" - default: - return title + " — " + status + " mecatui" - } -} - -func debugPhaseTitle(p phase) string { - if status := phaseStatusWord(p); status != "" { - return status - } - return "Ready" -} - -// phaseStatusWord maps a phase to the status WORD it contributes to the window -// title. phaseIdle and phaseReplay contribute "" (the title alone reads as -// "just sitting there" — no status word needed); the others carry a single word -// or glyph that survives a right-truncated tab bar. The glyphs (⚠/✗) are plain -// Unicode, not ANSI, so they never risk a terminal-escape interpretation. -func phaseStatusWord(p phase) string { - switch p { - case phaseRunning: - return "Working" - case phaseAwaitingApproval: - return "⚠" - case phaseConnecting: - return "Connecting" - case phaseFatal: - return "✗" - default: - // phaseIdle and phaseReplay: a title-known idle/replay session shows just - // the title + "mecatui" — no status word. - return "" - } -} - -// clampWindowTitle sanitizes a session title for a one-line terminal title: -// sanitizeTerminal strips C0/ESC/DEL (CWE-150), then strings.Fields collapses -// any surviving newlines/tabs (sanitizeTerminal preserves \n/\t for layout, -// but a window title is a single line) and trims surrounding whitespace. The -// result is clamped to windowTitleRunes via truncate (the shared rune-safe -// ellipsis clamp, view.go). An empty/whitespace-only input yields "" (the -// caller falls back to bare "mecatui"). It mirrors session.ClampTitle's -// contract at a tab-sized bound. -func clampWindowTitle(s string) string { - s = sanitizeTerminal(s) - s = strings.TrimSpace(strings.Join(strings.Fields(s), " ")) - if s == "" { - return "" - } - return truncate(s, windowTitleRunes) -} diff --git a/cmd/mecatui/ui/wintitle_test.go b/cmd/mecatui/ui/wintitle_test.go deleted file mode 100644 index d31a37c495..0000000000 --- a/cmd/mecatui/ui/wintitle_test.go +++ /dev/null @@ -1,384 +0,0 @@ -package ui - -import ( - "strings" - "testing" - "unicode/utf8" - - tea "charm.land/bubbletea/v2" - - "github.com/stacklok/mecatl/cmd/mecatui/client" - "github.com/stacklok/mecatl/cmd/mecatui/theme" -) - -// TestWindowTitle is the table test over windowTitle(): each phase mapping, the -// empty-title fallback, the 40-rune clamp + ellipsis, the newline/tab collapse, -// escape-sequence sanitization, and the NoWindowTitle gate. -func TestWindowTitle(t *testing.T) { - t.Parallel() - - // A title of exactly windowTitleRunes (40) runes must NOT be clamped. - exact := strings.Repeat("x", windowTitleRunes) - // A title one rune over the cap must clamp to 40 runes incl. the ellipsis. - over := strings.Repeat("y", windowTitleRunes+1) - - cases := []struct { - name string - model Model - want string - reason string - }{ - { - name: "running phase with title", - model: Model{ - phase: phaseRunning, - sessionTitle: "fix the login bug", - }, - want: "fix the login bug — Working mecatui", - reason: "phaseRunning → 'Working' word", - }, - { - name: "awaiting approval phase with title", - model: Model{ - phase: phaseAwaitingApproval, - sessionTitle: "fix the login bug", - }, - want: "fix the login bug — ⚠ mecatui", - reason: "phaseAwaitingApproval → '⚠' glyph", - }, - { - name: "connecting phase with title", - model: Model{ - phase: phaseConnecting, - sessionTitle: "fix the login bug", - }, - want: "fix the login bug — Connecting mecatui", - reason: "phaseConnecting → 'Connecting' word", - }, - { - name: "fatal phase with title", - model: Model{ - phase: phaseFatal, - sessionTitle: "fix the login bug", - }, - want: "fix the login bug — ✗ mecatui", - reason: "phaseFatal → '✗' glyph", - }, - { - name: "idle phase with title", - model: Model{ - phase: phaseIdle, - sessionTitle: "fix the login bug", - }, - want: "fix the login bug — mecatui", - reason: "phaseIdle → no status word (title + 'mecatui')", - }, - { - name: "replay phase with title", - model: Model{ - phase: phaseReplay, - sessionTitle: "fix the login bug", - }, - want: "fix the login bug — mecatui", - reason: "phaseReplay → no status word (title + 'mecatui')", - }, - { - name: "running phase no title → bare app + status", - model: Model{ - phase: phaseRunning, - sessionTitle: "", - }, - want: "Working mecatui", - reason: "empty title + status → '<status> mecatui'", - }, - { - name: "idle phase no title → bare 'mecatui'", - model: Model{ - phase: phaseIdle, - sessionTitle: "", - }, - want: "mecatui", - reason: "empty title + no status → bare 'mecatui'", - }, - { - name: "connecting phase no title → 'Connecting mecatui'", - model: Model{ - phase: phaseConnecting, - sessionTitle: "", - }, - want: "Connecting mecatui", - reason: "connecting (the launch phase) with no title still shows the word", - }, - { - name: "whitespace-only title → bare 'mecatui' at idle", - model: Model{ - phase: phaseIdle, - sessionTitle: " \t\n ", - }, - want: "mecatui", - reason: "whitespace-only title collapses to empty → bare 'mecatui'", - }, - { - name: "exact 40-rune title not clamped", - model: Model{ - phase: phaseIdle, - sessionTitle: exact, - }, - want: exact + " — mecatui", - reason: "40 runes fits the cap with no ellipsis", - }, - { - name: "over-cap title clamped to 40 runes incl ellipsis", - model: Model{ - phase: phaseIdle, - sessionTitle: over, - }, - want: strings.Repeat("y", windowTitleRunes-1) + "…" + " — mecatui", - reason: "41 runes clamps to 40 (39 y's + ellipsis)", - }, - { - name: "newline/tab collapse to single spaces", - model: Model{ - phase: phaseIdle, - sessionTitle: "fix\nthe\t\tlogin\n\nbug", - }, - want: "fix the login bug — mecatui", - reason: "newlines/tabs collapse to single spaces (one-line title)", - }, - { - name: "escape-sequence sanitization (OSC title injection)", - model: Model{ - phase: phaseIdle, - sessionTitle: "pwn\x1b]0;evil\x07title", - }, - want: "pwn]0;eviltitle — mecatui", - reason: "ESC (0x1b) and BEL (0x07) stripped (C0/ESC/DEL removed by sanitizeTerminal); the remaining printable chars are inert", - }, - { - name: "NoWindowTitle gate collapses to bare 'mecatui' even with a title", - model: Model{ - phase: phaseRunning, - deps: Deps{NoWindowTitle: true}, - sessionTitle: "fix the login bug", - }, - want: "mecatui", - reason: "NoWindowTitle=true → always bare 'mecatui'", - }, - { - name: "multibyte title clamp is rune-safe", - model: Model{ - phase: phaseIdle, - sessionTitle: strings.Repeat("é", windowTitleRunes+2), - }, - want: strings.Repeat("é", windowTitleRunes-1) + "…" + " — mecatui", - reason: "rune-safe clamp (no mid-character split on multibyte)", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := tc.model.windowTitle() - if got != tc.want { - t.Errorf("windowTitle() = %q, want %q (%s)", got, tc.want, tc.reason) - } - }) - } - - // Verify the over-cap clamp is rune-safe (the ellipsis counts toward the cap). - got := (Model{phase: phaseIdle, sessionTitle: over}).windowTitle() - titleSeg := strings.TrimSuffix(got, " — mecatui") - if n := utf8.RuneCountInString(titleSeg); n != windowTitleRunes { - t.Errorf("clamped title segment = %d runes, want %d (cap incl. ellipsis)", n, windowTitleRunes) - } -} - -// TestWindowTitleSeedsSetOnce asserts submitPrompt seeds the session title -// set-once: the FIRST prompt sticks, a SECOND prompt does NOT overwrite it. -func TestWindowTitleSeedsSetOnce(t *testing.T) { - m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) - m = applyAll(m, - tea.WindowSizeMsg{Width: 120, Height: 30}, - client.SessionReadyMsg{SessionID: "sess-title-0001"}, - ) - // First prompt seeds the title. - m = sendText(t, m, "refactor the auth module") - if m.sessionTitle != "refactor the auth module" || m.sessionTitleProvenance != "first-prompt" { - t.Fatalf("after first prompt title/provenance = %q/%q, want sent text/first-prompt", m.sessionTitle, m.sessionTitleProvenance) - } - // A second prompt must NOT overwrite the set-once title. - m = sendText(t, m, "now add tests") - if m.sessionTitle != "refactor the auth module" { - t.Errorf("after second prompt sessionTitle = %q, want the FIRST prompt to stick (set-once)", m.sessionTitle) - } -} - -// TestWindowTitleClearedByResetSession asserts resetSession clears the session -// title (a /clear wipes the session-derived label). -func TestWindowTitleClearedByResetSession(t *testing.T) { - m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) - m = applyAll(m, - tea.WindowSizeMsg{Width: 120, Height: 30}, - client.SessionReadyMsg{SessionID: "sess-reset-0001"}, - ) - m = sendText(t, m, "original task") - if m.sessionTitle != "original task" { - t.Fatalf("setup: sessionTitle = %q, want 'original task'", m.sessionTitle) - } - m = m.resetSession() - if m.sessionTitle != "" { - t.Errorf("after resetSession sessionTitle = %q, want empty", m.sessionTitle) - } -} - -// TestWindowTitleAdoptsResolvedModelMsgTitle asserts a current authoritative -// snapshot replaces the title, while a stale-session msg is dropped. -func TestWindowTitleAdoptsResolvedModelMsgTitle(t *testing.T) { - m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) - m = applyAll(m, - tea.WindowSizeMsg{Width: 120, Height: 30}, - client.SessionReadyMsg{SessionID: "sess-heal-0001"}, - ) - // No local title yet → adopt the server's stored title. - m = applyAll(m, client.ResolvedModelMsg{ - SessionID: "sess-heal-0001", - Title: "carryover task from a fork", - TitleProvenance: "operator", - }) - if m.sessionTitle != "carryover task from a fork" || m.sessionTitleProvenance != "operator" { - t.Fatalf("after self-heal title/provenance = %q/%q, want server title/operator", m.sessionTitle, m.sessionTitleProvenance) - } - // A newer authoritative snapshot replaces the prior title. - m = applyAll(m, client.ResolvedModelMsg{ - SessionID: "sess-heal-0001", - Title: "different server title", - }) - if m.sessionTitle != "different server title" { - t.Errorf("after second heal sessionTitle = %q, want current authoritative title", m.sessionTitle) - } -} - -// TestWindowTitleDropsStaleSessionResolvedModelMsg asserts a ResolvedModelMsg -// whose SessionID no longer matches the current session is dropped (a stale -// refetch must not seed a stale title). -func TestWindowTitleDropsStaleSessionResolvedModelMsg(t *testing.T) { - m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) - m = applyAll(m, - tea.WindowSizeMsg{Width: 120, Height: 30}, - client.SessionReadyMsg{SessionID: "sess-live-0001"}, - ) - // A stale-session msg (different SessionID) must be dropped entirely. - m = applyAll(m, client.ResolvedModelMsg{ - SessionID: "sess-OTHER-0001", - Title: "stale title from a dead session", - }) - if m.sessionTitle != "" { - t.Errorf("stale-session msg seeded sessionTitle = %q, want empty (dropped)", m.sessionTitle) - } -} - -// TestWindowTitleContinueSessionAdoptsTitle asserts authoritative continuation -// adopts the inventory title verbatim while the rendered window title clamps it. -func TestWindowTitleContinueSessionAdoptsTitle(t *testing.T) { - longTitle := strings.Repeat("z", windowTitleRunes+10) - row := client.SessionListItem{ - ID: "sess-stored-0001", Title: longTitle, TitleProvenance: "generated", Kind: client.SessionKindMain, - Capabilities: client.SessionInventoryCapabilities{PublicChat: true}, - } - loader := &fakeSessionTranscriptLoader{transcript: client.SessionTranscript{SessionID: row.ID, Complete: true}} - m := newSessionsModel(t, newSessionsConv(), &fakeSessionLister{sessions: []client.SessionListItem{row}}, loader) - ensureActiveSessions(&m).filtered = []client.SessionListItem{row} - mm, cmd, _ := m.chooseSession() - m = applyAll(mm.(Model), cmd()) - if m.sessionTitle != longTitle || m.sessionTitleProvenance != "generated" { - t.Errorf("continued title/provenance = %q/%q, want stored title/generated", m.sessionTitle, m.sessionTitleProvenance) - } - if got := m.windowTitle(); !strings.Contains(got, "…") { - t.Errorf("windowTitle() = %q, want it to clamp the over-cap stored title with an ellipsis", got) - } -} - -// TestWindowTitleHealRefetchRoundTrip proves the applySessionReady self-heal -// END-TO-END: a SessionReadyMsg with no local title fires a GetSession refetch, -// and feeding the result back adopts the server's stored title. It mirrors -// TestFooterHealRaceThenHeal's cmd-execution pattern (execute the batched cmd -// tree, assert the fake's GetSession ran, feed the msg back through the -// reducer). -func TestWindowTitleHealRefetchRoundTrip(t *testing.T) { - conv := &fakeConv{ - recv: &fakeRecver{}, send: &fakeSender{}, - getSessionTitle: "forked carryover task", - } - m := newTestModelFromDeps(Deps{ - Session: conv, - Conv: conv, - Theme: theme.New("aztec", theme.AztecPalette()), - Ctx: t.Context(), - NoAltScreen: true, - }) - m = applyAll(m, tea.WindowSizeMsg{Width: 120, Height: 30}) - - // 1) A session-ready with NO local title must fire the heal refetch. - mm, cmd := m.Update(client.SessionReadyMsg{SessionID: "sess-fork-0001"}) - m = mm.(Model) - if cmd == nil { - t.Fatal("applySessionReady with an empty title emitted no command — the heal refetch did not fire") - } - drainBatch(t, cmd()) - if n := conv.getSessionCalls(); n != 1 { - t.Fatalf("GetSession called %d times after session-ready with empty title, want 1", n) - } - - // 2) Feed the refetch result back: the title heals from the server's stored one. - m = applyAll(m, client.ResolvedModelMsg{SessionID: "sess-fork-0001", Title: "forked carryover task"}) - if m.sessionTitle != "forked carryover task" { - t.Fatalf("after heal round-trip sessionTitle = %q, want the server's stored title", m.sessionTitle) - } - if got := m.windowTitle(); got != "forked carryover task sess-fork-00 — mecatui" { - t.Errorf("windowTitle() = %q, want the healed title at idle", got) - } - - // 3) A second session-ready refreshes authoritative metadata, including a - // title changed by an asynchronous generator while the client was disconnected. - before := conv.getSessionCalls() - _, cmd = m.Update(client.SessionReadyMsg{SessionID: "sess-fork-0001"}) - if cmd == nil { - t.Fatal("session rebind emitted no metadata refresh") - } - drainBatch(t, cmd()) - if n := conv.getSessionCalls(); n != before+1 { - t.Errorf("GetSession calls = %d, want %d after session rebind metadata refresh", n, before+1) - } - m = applyAll(m, client.ResolvedModelMsg{ - SessionID: "sess-fork-0001", - Title: "newer server title", - State: "idle", - Placement: client.Placement{Kind: "local", Label: "workspace"}, - CreatedAt: 1_700_000_000, - }) - if m.sessionTitle != "newer server title" { - t.Errorf("metadata refresh title = %q, want current authoritative title", m.sessionTitle) - } -} - -// TestWindowTitleView ensures the UI no longer delegates title output to Bubble Tea. -func TestWindowTitleView(t *testing.T) { - m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) - m = applyAll(m, - tea.WindowSizeMsg{Width: 120, Height: 30}, - client.SessionReadyMsg{SessionID: "sess-view-0001"}, - ) - m = sendText(t, m, "investigate the flaky test") - if got := m.View().WindowTitle; got != "" { - t.Fatalf("View().WindowTitle = %q, want empty: title output belongs to the controller", got) - } -} - -// sendText types text into the textarea and submits it, exercising the -// submitPrompt reducer path (the same path a real enter-press takes). -func sendText(t *testing.T, m Model, text string) Model { - t.Helper() - m.prompt.Rewrite(text) - mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - return mm.(Model) -} diff --git a/docs/tui.md b/docs/tui.md index 4b48cb84c1..c8daa3536a 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -88,7 +88,8 @@ use `io.Discard`, so diagnostics cannot corrupt the terminal. Mecatui also owns the terminal title through the renderer's serialized output path. The client renders a plain-text title from the same display-safe status -input used by status templates and emits OSC 0 only when that title changes. A +input used by status templates, except that the title-specific projection omits +`Workspace.Path`. It emits OSC 0 only when that title changes. A clean exit clears the title after a non-empty title was emitted. The shipped default uses the session title and activity state, falls back to `mecatui`, and leaves the session handle out unless a custom template includes @@ -469,7 +470,8 @@ factory, or an unavailable target fails closed. The ordinary padded header place `DEBUG target <handle>` immediately after `mecatui` in every phase. At narrow widths it sheds model/mode/server detail before that complete target identity rather than clipping it; `/session` displays the safely quoted exact target ID and copies it with `t`. The -`DEBUG <handle>` terminal title uses the same handle. The TUI hides `/clear`, `/sessions`, `/models`, +terminal title uses the configured/default template with a `DEBUG` prefix and no +mandatory handle. The TUI hides `/clear`, `/sessions`, `/models`, `/effort`, and `/worktrees`, and blocks the mode/effort shortcuts because those controls can replace the launch binding. Schedule and learning controls remain available because changing those independent settings does not rebind the debug target; harmless inspection @@ -556,7 +558,7 @@ a short directive with a longer brief. The seed fires ONCE: a `/models` restart | `--version` | – | print the build identity and exit before normal startup | | `--inline` / `--no-alt-screen` | off | render inline in the terminal's normal buffer instead of the alternate screen, preserving native scrollback/search (no mouse capture; see `--no-mouse` below) | | `--no-mouse` | off | keep the alt screen but disable mouse capture and in-app mouse gestures, preserving the terminal's **native** click-drag selection; keyboard prompt selection still works (or `MECATUI_NO_MOUSE=1`; see the selection section) | -| `--terminal-title` | `on` | dynamic terminal window/tab title: `on` shows `<session title> <handle> — <status word> mecatui` (the title is the first prompt, the fixed handle identifies the session, and the status word reflects the phase); `off` collapses to the bare `mecatui` (escape hatch for terminals/multiplexers where a set title does more harm than good). Accepts `on`/`off`/`true`/`false`/`1`/`0` (or `MECATUI_NO_TERMINAL_TITLE=1`; see the terminal title section) | +| `--terminal-title` | `on` | terminal title controller: `on` enables the configured/default plain-text OSC 0 title; `off` emits no title or cleanup sequence. Accepts `on`/`off`/`true`/`false`/`1`/`0` (or `MECATUI_NO_TERMINAL_TITLE=1`; see the terminal title section) | | `--no-banner` | off | disable the first-run welcome **splash** (mascot + gradient wordmark); the plain prompt hint + affordance list still show. Auto-forced on under `--quiet` or a non-interactive stdin | | `--model` | – (provider default) | model id for the **embedded** server; empty = the server-configured `--default-model` (when set), else the provider-appropriate built-in (anthropic → `claude-sonnet-4-6`, openai → `gpt-5`, openrouter → `openai/gpt-5`; openai-codex → first entitled live model). Overridden per session by the `/models` picker | | `--default-provider` | – | **embedded** server: deployment-wide default provider id (e.g. `openai`, `openrouter`, `anthropic`, experimental `openai-codex`); overrides automatic preference for zero-selector sessions, while a client-side selection still wins. An unknown/unavailable provider **fails startup** | @@ -664,7 +666,7 @@ left owned after the bounded shutdown completes. |---|---| | `MECATUI_THEME` | theme name (same as `--theme`) | | `MECATUI_NO_MOUSE` | disable mouse capture and in-app mouse gestures (same as `--no-mouse`) while preserving native terminal selection; keyboard prompt selection still works | -| `MECATUI_NO_TERMINAL_TITLE` | suppress the dynamic terminal window/tab title (same as `--terminal-title=off`) — collapse to the bare `mecatui` | +| `MECATUI_NO_TERMINAL_TITLE` | disable all OSC title writes (same as `--terminal-title=off`) | | `MECATUI_DEBUG` | set to `1` to enable every client-side debug surface (same as `--debug` when that flag is omitted) | | `MECATUI_DEBUG_MOUSE` | legacy narrow alias: enable only the raw mouse-coordinate / click-mapping footer overlay | | `MECATUI_DEBUG_STEER` | legacy narrow alias: enable only steer acknowledgement/echo correlation in the status line | @@ -673,48 +675,11 @@ left owned after the bounded shutdown completes. | `MECATUI_FORCE_EMOJI` / `MECATUI_NO_EMOJI` | force / suppress the emoji glyph for the YOLO posture badge (force-on, no-wins-over-force); default is conservative env-based detection (see the posture badge) | | `MECATUI_FORCE_KITTY` / `MECATUI_NO_KITTY` | force / suppress the Kitty-graphics mascot on the welcome splash (force-on, no-wins-over-force); default is conservative env-based detection, falling back to the always-correct half-block mascot | -**Dynamic terminal window/tab title.** `mecatui` sets the terminal window/tab title -to `<session title> <handle> — <status word> mecatui`, so you can tell sessions apart -in a tab bar. The title is the **first genuine prompt** of the session (clamped to -~40 runes); `<handle>` is the fixed terminal-safe session handle; the status word -reflects the TUI phase: - -| Phase | Title | -|---|---| -| running | `<title> <handle> — Working mecatui` | -| awaiting approval | `<title> <handle> — ⚠ mecatui` | -| connecting | `<title> <handle> — Connecting mecatui` | -| fatal | `<title> <handle> — ✗ mecatui` | -| idle / replay (title known) | `<title> <handle> — mecatui` | -| no title yet (session known) | `<handle> — mecatui` | -| no session yet | `mecatui` | - -A session starts with its first genuine prompt as a fallback title; a generated or -operator title can later replace it. Automatic title generation is opt-in through an -explicit compatible `models.slots.title` binding, is server-owned and asynchronous, -and never changes the conversation or main-run budget. Its durable `session_title` -accounting is governed by [ADR 0307](adr/0307-canonical-durable-token-accounting.md). -See [ADR 0308](adr/0308-session-title-generation-and-auxiliary-usage.md). - -The title leads because tab bars **truncate from the right**; the status is a -**static word, never an animated spinner** (per-frame title churn trips OS -attention heuristics — the dock bounces / the taskbar flashes on every change). -The title self-heals across a session switch / fork / carryover (a refetch adopts -the server's stored title when this client never saw the first prompt). A dedicated -debugger instead always starts with `DEBUG <handle>`, followed by its static -phase label; the persistent amber/bold `DEBUG target <handle>` segment in the ordinary -padded header carries the same identity through every lifecycle and fatal state. - -The title is terminal-escape-sanitized (C0/ESC/DEL stripped — a malicious prompt -can't embed an OSC title-injection), and newlines/tabs collapse to single spaces -(a window title is one line). - -Pass `--terminal-title=off` (or `MECATUI_NO_TERMINAL_TITLE=1`) to suppress it and -leave the title at the bare `mecatui` — the escape hatch for -terminals/multiplexers where a set title does more harm than good. **tmux note:** -by default tmux's `automatic-rename` overrides pane titles; to let `mecatui`'s -title survive, set `set -g automatic-rename off` (or `set -g allow-set-title on`) -in your `~/.tmux.conf`. +**Terminal title compatibility.** Terminal and multiplexer title policies can +override OSC 0. By default tmux's `automatic-rename` overrides pane titles; to +let `mecatui`'s configured title survive, set `set -g automatic-rename off` (or +`set -g allow-set-title on`) in `~/.tmux.conf`. Use `--terminal-title=off` when +the terminal environment owns title presentation. **Skill discovery is ON by default**, via conventional discovery (the read-only `Skill` tool activates progressive-disclosure `<name>/SKILL.md` units from the diff --git a/user-docs/mecatui/status-line.md b/user-docs/mecatui/status-line.md index 189824b241..781548a7fe 100644 --- a/user-docs/mecatui/status-line.md +++ b/user-docs/mecatui/status-line.md @@ -339,7 +339,9 @@ session title exists. It does not include the session handle. Add `.Session.Handle` when you want a handle in the title. The title template gets the same display-safe input as status templates, including `Session`, `Model`, `Context`, `Usage`, `Workspace`, `Terminal`, `MainAgent`, `Delegation`, and -`Clock` values. It also supports `elide WIDTH VALUE`: a non-positive width is +`Clock` values. The title-specific projection excludes `Workspace.Path`; exact +local roots remain available only to status templates and direct status commands. +It also supports `elide WIDTH VALUE`: a non-positive width is empty, a fitting value is unchanged, width `1` is `…`, and wider values are truncated to the widest prefix that fits plus `…`. From 28257ee240381e57a95e904a5fac42d58815dda5 Mon Sep 17 00:00:00 2001 From: Joe Beda <joe@stacklok.com> Date: Wed, 16 Sep 2026 19:05:15 -0700 Subject: [PATCH 7/9] fix(mecatui): preserve visible text in title elision Co-Authored-By: mecatl <noreply@mecatl.dev> --- cmd/mecatui/client_settings_test.go | 31 ++++++++++++++++++++++++++--- cmd/mecatui/statusline/template.go | 2 +- cmd/mecatui/statusline/title.go | 14 ++++++++----- user-docs/mecatui/status-line.md | 2 +- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/cmd/mecatui/client_settings_test.go b/cmd/mecatui/client_settings_test.go index 4f0fb13548..e1a60a32f3 100644 --- a/cmd/mecatui/client_settings_test.go +++ b/cmd/mecatui/client_settings_test.go @@ -578,16 +578,41 @@ func TestADR_0344_Scenario2_SharedTemplateProjectionAndElide(t *testing.T) { t.Fatalf("wide elide = %q, %v (width %d), want %q within 5", got, err, ansi.StringWidth(got), "界界…") } + unsafeInput := statusline.Input{Session: statusline.Session{Title: "a<b>&def"}, Terminal: statusline.Terminal{HeaderAvailCols: 80}} + if got, err := mustTitleRenderer(t, "{{elide 10 .Session.Title}}").Render(unsafeInput); err != nil || got != "a<b>&def" { + t.Fatalf("title elide must measure visible text and preserve it as plain text: %q, %v", got, err) + } + + if _, err := statusline.NewTitleRenderer("{{contextMeter .Context}}"); err == nil { + t.Fatal("title template must not expose StatusML-producing contextMeter") + } + source := newSource(*settings.StatusCustomization) t.Cleanup(func() { _ = source.Close(context.Background()) }) - source.Submit(input) + source.Submit(unsafeInput) select { case <-source.Changed(): case <-time.After(time.Second): t.Fatal("template status source did not publish") } - if got := source.Latest().Header.Spans[0].Text; got != "abc…" { - t.Fatalf("status template elide = %q, want %q", got, "abc…") + if got := source.Latest().Header.Spans[0].Text; got != "a<b…" { + t.Fatalf("status template elide must preserve escaped visible text = %q, want %q", got, "a<b…") + } + + escapedSource := statusline.NewTemplateSource(statusline.TemplateSet{Header: statusline.SurfaceTemplates{ + Full: "<header><text>{{elide 10 .Session.Title}}</text></header>", + Compact: "<header><text>{{elide 10 .Session.Title}}</text></header>", + Minimal: "<header><text>{{elide 10 .Session.Title}}</text></header>", + }}, 0) + t.Cleanup(func() { _ = escapedSource.Close(context.Background()) }) + escapedSource.Submit(unsafeInput) + select { + case <-escapedSource.Changed(): + case <-time.After(time.Second): + t.Fatal("escaped template status source did not publish") + } + if got := escapedSource.Latest().Header.Spans[0].Text; got != "a<b>&def" { + t.Fatalf("status template elide must retain visible escaped text = %q, want %q", got, "a<b>&def") } } diff --git a/cmd/mecatui/statusline/template.go b/cmd/mecatui/statusline/template.go index 030bc0fa54..363e64b0db 100644 --- a/cmd/mecatui/statusline/template.go +++ b/cmd/mecatui/statusline/template.go @@ -62,7 +62,7 @@ func firstTemplate(given, fallback string) string { return fallback } func parseStatusTemplate(name, source, fallback string) statusTemplate { - funcs := templateFuncs() + funcs := statusTemplateFuncs() fallbackTemplate, err := template.New(name).Funcs(funcs).Option("missingkey=error").Parse(fallback) if err != nil { return statusTemplate{} diff --git a/cmd/mecatui/statusline/title.go b/cmd/mecatui/statusline/title.go index 0819ab07d7..0e2e37b0fc 100644 --- a/cmd/mecatui/statusline/title.go +++ b/cmd/mecatui/statusline/title.go @@ -30,7 +30,7 @@ func newTitleTemplateInput(input Input) titleTemplateInput { // catches template expressions which parse successfully but cannot execute // against the status projection. func NewTitleRenderer(source string) (*TitleRenderer, error) { - t, err := template.New("terminal_title").Funcs(templateFuncs()).Option("missingkey=error").Parse(source) + t, err := template.New("terminal_title").Funcs(titleTemplateFuncs()).Option("missingkey=error").Parse(source) if err != nil { return nil, err } @@ -51,7 +51,11 @@ func (r *TitleRenderer) Render(input Input) (string, error) { return html.UnescapeString(output.String()), nil } -func templateFuncs() template.FuncMap { +func titleTemplateFuncs() template.FuncMap { + return template.FuncMap{"elide": elide} +} + +func statusTemplateFuncs() template.FuncMap { return template.FuncMap{ "contextMeter": contextMeter, "contextMeterCompact": contextMeterCompact, @@ -64,14 +68,14 @@ func elide(width int, value any) string { if width <= 0 { return "" } - text := stringifyTemplateValue(value) + text := html.UnescapeString(stringifyTemplateValue(value)) if ansi.StringWidth(text) <= width { - return text + return html.EscapeString(text) } if width == 1 { return "…" } - return ansi.Truncate(text, width, "…") + return html.EscapeString(ansi.Truncate(text, width, "…")) } func stringifyTemplateValue(value any) string { diff --git a/user-docs/mecatui/status-line.md b/user-docs/mecatui/status-line.md index 781548a7fe..f47810b2d1 100644 --- a/user-docs/mecatui/status-line.md +++ b/user-docs/mecatui/status-line.md @@ -101,7 +101,7 @@ refreshes it. |`Server.DisplayTarget`|string|Credential-free target shown by the client.| |`Server.ConnectionMode`|string|`embedded`, `connect`, or empty while unknown.| |`Session.Title`|string|Optional display title.| -|`Session.Handle`|string|Fixed 12-column ordinary session handle used by shipped headers: safe `[A-Za-z0-9._-]` bytes are literal except that a leading `-` is encoded as `%2D`; other UTF-8 bytes are uppercase `%HH`, and only complete atoms that fit are included. It has no leading `#` and replaces the v1 `Session.Digest` field in protocol v2; no digest alias is emitted.| +|`Session.Handle`|string|Fixed 12-column ordinary session handle available to custom status and terminal-title templates: safe `[A-Za-z0-9._-]` bytes are literal except that a leading `-` is encoded as `%2D`; other UTF-8 bytes are uppercase `%HH`, and only complete atoms that fit are included. It has no leading `#` and replaces the v1 `Session.Digest` field in protocol v2; no digest alias is emitted.| |`Session.Mode`|string|Active or pending permission mode used by the shipped header.| |`Session.ReasoningEffort`|string|`low`, `medium`, `high`, `xhigh`, `max`, or empty.| |`Model.ProviderID`, `Model.ID`, `Model.DisplayName`, `Model.Route`|strings|Provider/model routing identifiers, display label, and observed downstream route.| From 4df3330b4241dceab2a62afa8b86d1d5f5643491 Mon Sep 17 00:00:00 2001 From: Joe Beda <joe@stacklok.com> Date: Wed, 16 Sep 2026 19:26:02 -0700 Subject: [PATCH 8/9] fix(mecatui): deliver terminal title updates Co-Authored-By: mecatl <noreply@mecatl.dev> --- cmd/mecatui/main.go | 10 +++-- cmd/mecatui/statusline/title.go | 27 ++++++++++-- cmd/mecatui/terminal_title_controller.go | 41 ++++++++++++------- cmd/mecatui/terminal_title_controller_test.go | 23 +++++++++++ 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index 2f9c4f70cc..2148fb3c7a 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -153,6 +153,10 @@ func newMecatuiProgram(ctx context.Context, deps ui.Deps, title *terminalTitleCo return tea.NewProgram(ui.New(deps), tea.WithContext(ctx), tea.WithOutput(title)) } +func closeTerminalTitle(title *terminalTitleController) error { + return title.Close() +} + func run(argv []string) error { return runWithOptions(argv, runOptions{}) } @@ -406,10 +410,8 @@ func runWithOptions(argv []string, options runOptions) error { prog := newMecatuiProgram(ctx, deps, title) finalModel, runErr := prog.Run() interrupted := ctx.Err() != nil - if runErr == nil && !interrupted { - if err := title.Close(); err != nil { - runErr = err - } + if err := closeTerminalTitle(title); runErr == nil && err != nil { + runErr = err } runCleanup(forceExit, func() { diff --git a/cmd/mecatui/statusline/title.go b/cmd/mecatui/statusline/title.go index 0e2e37b0fc..505cbcf6e0 100644 --- a/cmd/mecatui/statusline/title.go +++ b/cmd/mecatui/statusline/title.go @@ -13,16 +13,35 @@ import ( type TitleRenderer struct{ template *template.Template } type titleTemplateInput struct { - templateInput - Workspace titleTemplateWorkspace + Version uint8 + Server templateServer + Session templateSession + Model templateModel + Usage templateUsage + Context templateContext + Workspace titleTemplateWorkspace + Terminal Terminal + MainAgent templateMainAgent + Delegation templateDelegation + Clock templateClock } type titleTemplateWorkspace struct{ Location, Name templateText } func newTitleTemplateInput(input Input) titleTemplateInput { + projection := newTemplateInput(input) return titleTemplateInput{ - templateInput: newTemplateInput(input), - Workspace: titleTemplateWorkspace{escapeTemplateText(input.Workspace.Location), escapeTemplateText(input.Workspace.Name)}, + Version: projection.Version, + Server: projection.Server, + Session: projection.Session, + Model: projection.Model, + Usage: projection.Usage, + Context: projection.Context, + Workspace: titleTemplateWorkspace{projection.Workspace.Location, projection.Workspace.Name}, + Terminal: projection.Terminal, + MainAgent: projection.MainAgent, + Delegation: projection.Delegation, + Clock: projection.Clock, } } diff --git a/cmd/mecatui/terminal_title_controller.go b/cmd/mecatui/terminal_title_controller.go index f6d96b8fa8..4369e828b5 100644 --- a/cmd/mecatui/terminal_title_controller.go +++ b/cmd/mecatui/terminal_title_controller.go @@ -12,8 +12,8 @@ import ( const terminalTitleRunes = 160 -// terminalTitleController owns title emission through Bubble Tea's renderer -// output writer. Set runs during View; Write is the renderer-serialized path. +// terminalTitleController is the sole terminal-title output authority. Set runs +// during View; its mutex serializes OSC updates with Bubble Tea frame writes. type terminalTitleController struct { mu sync.Mutex output io.Writer @@ -24,6 +24,7 @@ type terminalTitleController struct { wrote bool debug bool renderErr error + writeErr error } func newTerminalTitleController(output io.Writer, enabled bool, renderer *statusline.TitleRenderer) *terminalTitleController { @@ -40,7 +41,7 @@ func terminalTitleEnabled(cfg config, settings terminalTitleSettings) bool { func (c *terminalTitleController) Set(input statusline.Input) { c.mu.Lock() defer c.mu.Unlock() - if !c.enabled || c.renderErr != nil { + if !c.enabled || c.renderErr != nil || c.writeErr != nil { return } title, err := c.renderer.Render(input) @@ -52,6 +53,21 @@ func (c *terminalTitleController) Set(input statusline.Input) { title = "DEBUG " + title } c.pending = sanitizeTerminalTitle(title) + c.flush() +} + +func (c *terminalTitleController) flush() { + if c.pending == c.last || (c.pending == "" && !c.wrote) { + return + } + if _, err := io.WriteString(c.output, "\x1b]0;"+c.pending+"\a"); err != nil { + c.writeErr = err + return + } + c.last = c.pending + if c.last != "" { + c.wrote = true + } } func (c *terminalTitleController) Write(p []byte) (int, error) { @@ -60,14 +76,8 @@ func (c *terminalTitleController) Write(p []byte) (int, error) { if c.renderErr != nil { return 0, c.renderErr } - if c.enabled && c.pending != c.last && (c.pending != "" || c.wrote) { - if _, err := io.WriteString(c.output, "\x1b]0;"+c.pending+"\a"); err != nil { - return 0, err - } - c.last = c.pending - if c.last != "" { - c.wrote = true - } + if c.writeErr != nil { + return 0, c.writeErr } return c.output.Write(p) } @@ -75,6 +85,9 @@ func (c *terminalTitleController) Write(p []byte) (int, error) { func (c *terminalTitleController) Close() error { c.mu.Lock() defer c.mu.Unlock() + if c.writeErr != nil { + return c.writeErr + } if c.enabled && c.wrote && c.last != "" { if _, err := io.WriteString(c.output, "\x1b]0;\a"); err != nil { return err @@ -90,11 +103,11 @@ func sanitizeTerminalTitle(value string) string { space := true count := 0 for _, r := range value { - if unicode.IsSpace(r) { - space = true + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { continue } - if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + if unicode.IsSpace(r) { + space = true continue } if space && out.Len() > 0 { diff --git a/cmd/mecatui/terminal_title_controller_test.go b/cmd/mecatui/terminal_title_controller_test.go index 219ef865f5..d202071147 100644 --- a/cmd/mecatui/terminal_title_controller_test.go +++ b/cmd/mecatui/terminal_title_controller_test.go @@ -15,6 +15,9 @@ func TestADR_0344_Scenario1_ControllerOwnsSerializedOSC0(t *testing.T) { controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}} · {{.MainAgent.State}}")) controller.Set(statusline.Input{Session: statusline.Session{Title: "first"}, MainAgent: statusline.MainAgent{State: "idle"}}) + if got := output.String(); got != "\x1b]0;first · idle\a" { + t.Fatalf("title change must be delivered without a Bubble Tea frame: %q", got) + } if _, err := controller.Write([]byte("frame one")); err != nil { t.Fatalf("write first frame: %v", err) } @@ -110,7 +113,24 @@ func TestADR_0344_Scenario1_DeduplicatesConditionalCleanupAndDisables(t *testing }) } +func TestTerminalTitleCleanupAfterGracefulCancellation(t *testing.T) { + var output bytes.Buffer + controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}}")) + controller.Set(statusline.Input{Session: statusline.Session{Title: "running"}}) + + if err := closeTerminalTitle(controller); err != nil { + t.Fatalf("close title after context cancellation: %v", err) + } + if got := output.String(); got != "\x1b]0;running\a\x1b]0;\a" { + t.Fatalf("graceful cancellation cleanup = %q, want title followed by one clear", got) + } +} + func TestADR_0344_Scenario1_SanitizesRenderedTitle(t *testing.T) { + if got := sanitizeTerminalTitle("one\u0085two"); got != "onetwo" { + t.Fatalf("terminal control must be stripped before whitespace collapse: %q", got) + } + var output bytes.Buffer controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}}")) controller.Set(statusline.Input{Session: statusline.Session{Title: " one\x1b]2;injected\a\u007f\u0085\u2000two\u200b\nthree\t " + strings.Repeat("x", 512)}}) @@ -210,6 +230,9 @@ func TestADR_0344_Scenario3_LocalAndRemotePresentation(t *testing.T) { if outputs[0] != outputs[1] || strings.Contains(outputs[0], "/private/workspace") { t.Fatalf("embedded=%q connect=%q; title must be connection-independent and path-free", outputs[0], outputs[1]) } + if got := renderTitle(t, "{{printf \"%+v\" .}}", input); strings.Contains(got, "/private/workspace") { + t.Fatalf("formatted title projection leaked workspace path: %q", got) + } if _, err := statusline.NewTitleRenderer("{{.Workspace.Path}}"); err == nil || !strings.Contains(err.Error(), "Path") { t.Fatalf("title Workspace.Path projection error = %v, want unavailable-field error", err) } From 213181c74232aa95deb7ed56a7d92bd90b9b2f24 Mon Sep 17 00:00:00 2001 From: Joe Beda <joe@stacklok.com> Date: Mon, 21 Sep 2026 11:25:27 -0700 Subject: [PATCH 9/9] refactor(mecatui): simplify title presentation Co-Authored-By: mecatl <noreply@mecatl.dev> --- cmd/mecatui/client/client.go | 81 +++++------------- cmd/mecatui/client/debug_test.go | 85 ++++++++++--------- cmd/mecatui/client/session_affinity_test.go | 10 ++- ...ctable_session_handles_integration_test.go | 2 +- cmd/mecatui/statusline/template.go | 37 ++++++++ cmd/mecatui/statusline/title.go | 79 +---------------- cmd/mecatui/terminal_title_controller.go | 16 ++-- cmd/mecatui/terminal_title_controller_test.go | 51 +++++++++-- cmd/mecatui/ui/model.go | 2 +- .../ui/predictable_session_handles_test.go | 38 ++++----- go.mod | 1 - user-docs/mecatui/sessions.md | 15 ++-- user-docs/mecatui/status-line.md | 52 ++++++------ 13 files changed, 213 insertions(+), 256 deletions(-) diff --git a/cmd/mecatui/client/client.go b/cmd/mecatui/client/client.go index dd0518aeed..189007c779 100644 --- a/cmd/mecatui/client/client.go +++ b/cmd/mecatui/client/client.go @@ -16,6 +16,7 @@ import ( "unicode" "unicode/utf8" + "github.com/charmbracelet/x/ansi" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -295,52 +296,35 @@ func (c *Client) CreateSession(ctx context.Context, mode mecatlv1.PermissionMode }) } -// SessionHandleWidth is the fixed maximum ASCII-column width of every ordinary +// SessionHandleWidth is the fixed maximum display-column width of every ordinary // session handle shown by mecatui. const SessionHandleWidth = 12 -// SessionHandle returns the fixed, terminal-safe escaped prefix used by every -// ordinary mecatui session presentation. Unreserved ASCII is copied verbatim, -// except that a leading hyphen is escaped; every other UTF-8 byte is one -// uppercase %HH atom. The longest complete-atom -// prefix fitting SessionHandleWidth is returned. Empty or invalid UTF-8 IDs have -// no handle. +// SessionHandle returns the canonical safe display projection of id. It retains +// readable UTF-8 verbatim except terminal control and Unicode format characters, +// then truncates at a grapheme boundary to SessionHandleWidth display columns. +// Empty and invalid UTF-8 IDs have no handle. func SessionHandle(id string) string { if id == "" || !utf8.ValidString(id) { return "" } - const hex = "0123456789ABCDEF" - var out strings.Builder - out.Grow(SessionHandleWidth) - for i, b := range []byte(id) { - safe := b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '_' || b == '-' && i > 0 - atomLen := 3 - if safe { - atomLen = 1 + id = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return -1 } - if out.Len()+atomLen > SessionHandleWidth { - break - } - if safe { - out.WriteByte(b) - } else { - out.WriteByte('%') - out.WriteByte(hex[b>>4]) - out.WriteByte(hex[b&0x0f]) - } - } - return out.String() + return r + }, id) + return ansi.Truncate(id, SessionHandleWidth, "") } // CreateDebugSession creates a separate no-filesystem analysis session bound to -// targetID. A target with the canonical short-handle grammar is resolved against -// the caller-visible session inventory. Exact inventory equality wins; otherwise -// a unique projected handle resolves to its full ID. An inventory failure or no -// projected match leaves the target unchanged so the server's exact-ID authority -// decides the result. Capability absence is detected from the create response -// (the first common response carrying ServerCapabilities); an older server may -// ignore the new target field, so that accidentally-created ordinary session is -// closed before this method fails closed. +// targetID. Exact inventory equality wins; otherwise a unique displayed handle +// resolves to its full ID. An inventory failure or no displayed-handle match +// leaves the target unchanged so the server's exact-ID authority decides the +// result. Capability absence is detected from the create response (the first +// common response carrying ServerCapabilities); an older server may ignore the +// new target field, so that accidentally-created ordinary session is closed +// before this method fails closed. func (c *Client) CreateDebugSession(ctx context.Context, targetID string, mode mecatlv1.PermissionMode, sel ModelSelection, debugMCP ...string) (string, string, Capabilities, ResolvedModel, error) { resolvedTarget, err := c.resolveDebugTarget(ctx, targetID) if err != nil { @@ -390,9 +374,6 @@ func (c *Client) resolveDebugTarget(ctx context.Context, targetID string) (strin if err := validateDebugTarget(targetID); err != nil { return "", err } - if !isSessionHandleCandidate(targetID) { - return targetID, nil - } sessions, err := c.ListSessions(ctx) if err != nil { return targetID, nil @@ -412,7 +393,7 @@ func (c *Client) resolveDebugTarget(ctx context.Context, targetID string) (strin } } if len(matches) > 1 { - return "", fmt.Errorf("session handle %q is ambiguous; %s", targetID, debugTargetExactCopyGuidance) + return "", fmt.Errorf("displayed session ID %q is ambiguous; %s", targetID, debugTargetExactCopyGuidance) } if len(matches) == 0 { return targetID, nil @@ -420,28 +401,6 @@ func (c *Client) resolveDebugTarget(ctx context.Context, targetID string) (strin return matches[0], nil } -func isSessionHandleCandidate(value string) bool { - if value == "" || len(value) > SessionHandleWidth { - return false - } - for i := 0; i < len(value); i++ { - b := value[i] - literal := b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '_' || b == '-' && i > 0 - if literal { - continue - } - if b != '%' || i+2 >= len(value) || !isUpperHex(value[i+1]) || !isUpperHex(value[i+2]) { - return false - } - i += 2 - } - return true -} - -func isUpperHex(b byte) bool { - return b >= '0' && b <= '9' || b >= 'A' && b <= 'F' -} - // CreateSessionWithCarryover forks sourceSessionID with model overrides. Server // inheritance supplies placement, mode, limits, and any omitted model fields. func (c *Client) CreateSessionWithCarryover(ctx context.Context, sel ModelSelection, sourceSessionID string) (string, Capabilities, ResolvedModel, error) { diff --git a/cmd/mecatui/client/debug_test.go b/cmd/mecatui/client/debug_test.go index 56e379d11f..b18e0c5604 100644 --- a/cmd/mecatui/client/debug_test.go +++ b/cmd/mecatui/client/debug_test.go @@ -6,6 +6,7 @@ import ( "net" "strings" "testing" + "unicode" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -119,6 +120,12 @@ func TestPredictableSessionHandles_Scenario1_OnlyHandleWidthAPI(t *testing.T) { if got := SessionHandle("123456789012-rest"); got != "123456789012" { t.Fatalf("SessionHandle = %q", got) } + if got := SessionHandle("1234567890雪-rest"); got != "1234567890雪" { + t.Fatalf("SessionHandle must not split graphemes: %q", got) + } + if got := SessionHandle("safe\x1b\n\t\u202e-handle"); got != "safe-handle" { + t.Fatalf("SessionHandle must remove control and format characters: %q", got) + } if got := SessionHandle("short"); got != "short" { t.Fatalf("SessionHandle short = %q", got) } @@ -143,11 +150,11 @@ func TestCreateDebugSessionResolution(t *testing.T) { {name: "exact equality precedes projection", target: handle, sessions: []*mecatlv1.SessionSummary{{SessionId: handle + "-projected"}, {SessionId: handle}}, wantTarget: handle, wantCreates: 1, wantLists: 1}, {name: "unique projection", target: handle, sessions: []*mecatlv1.SessionSummary{{SessionId: handle + "-full"}, {SessionId: "other"}}, wantTarget: handle + "-full", wantCreates: 1, wantLists: 1}, {name: "duplicate inventory row is one match", target: handle, sessions: []*mecatlv1.SessionSummary{{SessionId: handle + "-full"}, {SessionId: handle + "-full"}}, wantTarget: handle + "-full", wantCreates: 1, wantLists: 1}, - {name: "ambiguous projection", target: handle, sessions: []*mecatlv1.SessionSummary{{SessionId: handle + "-one"}, {SessionId: handle + "-two"}}, wantErr: "full exact session ID", wantLists: 1}, + {name: "ambiguous displayed ID", target: handle, sessions: []*mecatlv1.SessionSummary{{SessionId: handle + "-one"}, {SessionId: handle + "-two"}}, wantErr: "full exact session ID", wantLists: 1}, {name: "no match falls through exact", target: handle, createErr: errors.New("server exact lookup: not found"), wantTarget: handle, wantErr: "server exact lookup: not found", wantCreates: 1, wantLists: 1}, {name: "inventory failure falls through exact", target: handle, listErr: errors.New("inventory unavailable"), createErr: errors.New("server exact lookup: denied"), wantTarget: handle, wantErr: "server exact lookup: denied", wantCreates: 1, wantLists: 1}, - {name: "long exact bypasses inventory", target: handle + "-full", wantTarget: handle + "-full", wantCreates: 1}, - {name: "non-handle exact bypasses inventory", target: "-leading", wantTarget: "-leading", wantCreates: 1}, + {name: "long exact wins", target: handle + "-full", sessions: []*mecatlv1.SessionSummary{{SessionId: handle + "-full"}}, wantTarget: handle + "-full", wantCreates: 1, wantLists: 1}, + {name: "leading hyphen displayed ID resolves", target: "-leading-res", sessions: []*mecatlv1.SessionSummary{{SessionId: "-leading-rest"}}, wantTarget: "-leading-rest", wantCreates: 1, wantLists: 1}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -172,55 +179,49 @@ func TestCreateDebugSessionResolution(t *testing.T) { } } -func TestPredictableSessionHandles_Scenario2_HandleGrammarAndUnifiedTarget(t *testing.T) { +func TestCreateDebugSessionResolvesRenderedControlSafeHandle(t *testing.T) { + const fullID = "visible\x1b\n\t\u202e-handle-rest" + handle := SessionHandle(fullID) + if handle != "visible-hand" || strings.ContainsFunc(handle, func(r rune) bool { return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) }) { + t.Fatalf("SessionHandle(%q) = %q, want a control-safe actionable handle", fullID, handle) + } + + fake := &debugHarness{ + caps: &mecatlv1.ServerCapabilities{SessionDebug: true}, + sessions: []*mecatlv1.SessionSummary{{SessionId: fullID}}, + } + cl := &Client{svc: fake} + _, resolved, _, _, err := cl.CreateDebugSession(context.Background(), handle, 0, ModelSelection{}) + if err != nil { + t.Fatal(err) + } + if fake.request.GetDebugTargetSessionId() != fullID || resolved != fullID { + t.Fatalf("rendered handle %q resolved target=%q, returned=%q, want %q", handle, fake.request.GetDebugTargetSessionId(), resolved, fullID) + } +} + +func TestCreateDebugSessionResolvesAnyDisplayedUTF8Handle(t *testing.T) { tests := []struct { - name string - target string - fullID string - candidate bool - reject bool + name, target, fullID string }{ - {name: "empty", target: "", reject: true}, - {name: "one safe atom", target: "a", fullID: "a", candidate: true}, - {name: "exactly twelve safe", target: "abcdefghijkl", fullID: "abcdefghijkl", candidate: true}, - {name: "uppercase escape exactly fits", target: "123456789%2F", fullID: "123456789/", candidate: true}, - {name: "uppercase escapes", target: "%C3%A9", fullID: "é", candidate: true}, - {name: "escape cannot fit", target: "1234567890%2F"}, - {name: "too long", target: "abcdefghijklm"}, - {name: "lowercase escape", target: "%2f"}, - {name: "truncated escape", target: "%2"}, - {name: "malformed escape", target: "%GG"}, - {name: "leading hyphen", target: "-legacy"}, - {name: "literal percent", target: "abc%def"}, - {name: "other ascii", target: "abc/def"}, - {name: "multibyte", target: "é"}, - {name: "invalid utf8", target: string([]byte{0xff}), reject: true}, + {name: "leading hyphen", target: "-legacy-hand", fullID: "-legacy-handle"}, + {name: "percent", target: "%2F-full-id-", fullID: "%2F-full-id-rest"}, + {name: "slash", target: "abc/def-rest", fullID: "abc/def-rest-more"}, + {name: "multibyte", target: "éclair-sessi", fullID: "éclair-session-more"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - fake := &debugHarness{caps: &mecatlv1.ServerCapabilities{SessionDebug: true}} - if tc.candidate { - fake.sessions = []*mecatlv1.SessionSummary{{SessionId: tc.fullID}} + fake := &debugHarness{ + caps: &mecatlv1.ServerCapabilities{SessionDebug: true}, + sessions: []*mecatlv1.SessionSummary{{SessionId: tc.fullID}}, } cl := &Client{svc: fake} - _, _, _, _, err := cl.CreateDebugSession(context.Background(), tc.target, 0, ModelSelection{}) - if tc.reject { - if err == nil || fake.createCalls != 0 || fake.listCalls != 0 { - t.Fatalf("rejected target error=%v create=%d list=%d", err, fake.createCalls, fake.listCalls) - } - return - } + _, resolved, _, _, err := cl.CreateDebugSession(context.Background(), tc.target, 0, ModelSelection{}) if err != nil { t.Fatal(err) } - wantLists := 0 - wantTarget := tc.target - if tc.candidate { - wantLists = 1 - wantTarget = tc.fullID - } - if fake.listCalls != wantLists || fake.request.GetDebugTargetSessionId() != wantTarget { - t.Fatalf("lists=%d target=%q, want lists=%d target=%q", fake.listCalls, fake.request.GetDebugTargetSessionId(), wantLists, wantTarget) + if fake.listCalls != 1 || fake.request.GetDebugTargetSessionId() != tc.fullID || resolved != tc.fullID { + t.Fatalf("lists=%d target=%q resolved=%q, want %q", fake.listCalls, fake.request.GetDebugTargetSessionId(), resolved, tc.fullID) } }) } diff --git a/cmd/mecatui/client/session_affinity_test.go b/cmd/mecatui/client/session_affinity_test.go index 1562643857..f9e3adff0d 100644 --- a/cmd/mecatui/client/session_affinity_test.go +++ b/cmd/mecatui/client/session_affinity_test.go @@ -113,10 +113,16 @@ func TestSessionAffinityAndHandoff_Scenario4_MecatuiUnaryAndStreamPropagation(t }) } - if len(conn.calls) != len(calls) { - t.Fatalf("recorded calls = %d, want %d", len(conn.calls), len(calls)) + if len(conn.calls) != len(calls)+1 { + t.Fatalf("recorded calls = %d, want %d", len(conn.calls), len(calls)+1) } for _, call := range conn.calls { + if strings.HasSuffix(call.method, "/ListSessions") { + if got := call.md.Get(sessionaffinity.HeaderName); !reflect.DeepEqual(got, []string{"stale-binding"}) { + t.Errorf("%s session metadata = %#v, want preserved caller value", call.method, got) + } + continue + } if got := call.md.Get(sessionaffinity.HeaderName); !reflect.DeepEqual(got, []string{sessionID}) { t.Errorf("%s session metadata = %#v, want exact %q", call.method, got, sessionID) } diff --git a/cmd/mecatui/predictable_session_handles_integration_test.go b/cmd/mecatui/predictable_session_handles_integration_test.go index 82ed12dfb4..099b5fd72e 100644 --- a/cmd/mecatui/predictable_session_handles_integration_test.go +++ b/cmd/mecatui/predictable_session_handles_integration_test.go @@ -54,7 +54,7 @@ var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) func TestPredictableSessionHandles_Scenario2_RenderedHeaderCreatesBoundDebugger(t *testing.T) { ids := []string{ "0123456789abcdef0123456789abcdef", - "legacy\x1b-session-é", + "legacy-session-é", } service := &renderedHeaderDebugServer{ids: ids} listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/cmd/mecatui/statusline/template.go b/cmd/mecatui/statusline/template.go index 363e64b0db..94510b2b47 100644 --- a/cmd/mecatui/statusline/template.go +++ b/cmd/mecatui/statusline/template.go @@ -73,6 +73,43 @@ func parseStatusTemplate(name, source, fallback string) statusTemplate { } return statusTemplate{template: parsed, fallback: fallbackTemplate} } + +func commonTemplateFuncs() template.FuncMap { + return template.FuncMap{"elide": elide} +} + +func statusTemplateFuncs() template.FuncMap { + funcs := commonTemplateFuncs() + funcs["contextMeter"] = contextMeter + funcs["contextMeterCompact"] = contextMeterCompact + funcs["contextMeterMinimal"] = contextMeterMinimal + return funcs +} + +func elide(width int, value any) string { + if width <= 0 { + return "" + } + text := html.UnescapeString(stringifyTemplateValue(value)) + if ansi.StringWidth(text) <= width { + return html.EscapeString(text) + } + if width == 1 { + return "…" + } + return html.EscapeString(ansi.Truncate(text, width, "…")) +} + +func stringifyTemplateValue(value any) string { + switch value := value.(type) { + case templateText: + return string(value) + case string: + return value + default: + return "" + } +} func (t statusTemplate) render(ctx context.Context, input templateInput) Document { if ctx.Err() != nil || t.fallback == nil { return Document{} diff --git a/cmd/mecatui/statusline/title.go b/cmd/mecatui/statusline/title.go index 505cbcf6e0..7f0d3fbb10 100644 --- a/cmd/mecatui/statusline/title.go +++ b/cmd/mecatui/statusline/title.go @@ -4,57 +4,22 @@ import ( "html" "strings" "text/template" - - "github.com/charmbracelet/x/ansi" ) // TitleRenderer renders a plain-text title from the display-safe status input. // It deliberately has no StatusML parsing or command-source integration. type TitleRenderer struct{ template *template.Template } -type titleTemplateInput struct { - Version uint8 - Server templateServer - Session templateSession - Model templateModel - Usage templateUsage - Context templateContext - Workspace titleTemplateWorkspace - Terminal Terminal - MainAgent templateMainAgent - Delegation templateDelegation - Clock templateClock -} - -type titleTemplateWorkspace struct{ Location, Name templateText } - -func newTitleTemplateInput(input Input) titleTemplateInput { - projection := newTemplateInput(input) - return titleTemplateInput{ - Version: projection.Version, - Server: projection.Server, - Session: projection.Session, - Model: projection.Model, - Usage: projection.Usage, - Context: projection.Context, - Workspace: titleTemplateWorkspace{projection.Workspace.Location, projection.Workspace.Name}, - Terminal: projection.Terminal, - MainAgent: projection.MainAgent, - Delegation: projection.Delegation, - Clock: projection.Clock, - } -} - // NewTitleRenderer validates and compiles a title template. The startup render // catches template expressions which parse successfully but cannot execute // against the status projection. func NewTitleRenderer(source string) (*TitleRenderer, error) { - t, err := template.New("terminal_title").Funcs(titleTemplateFuncs()).Option("missingkey=error").Parse(source) + t, err := template.New("terminal_title").Funcs(commonTemplateFuncs()).Option("missingkey=error").Parse(source) if err != nil { return nil, err } var output strings.Builder - if err := t.Execute(&output, newTitleTemplateInput(Input{})); err != nil { + if err := t.Execute(&output, newTemplateInput(Input{})); err != nil { return nil, err } return &TitleRenderer{template: t}, nil @@ -64,46 +29,8 @@ func NewTitleRenderer(source string) (*TitleRenderer, error) { // controller owns final terminal-control sanitization and bounds. func (r *TitleRenderer) Render(input Input) (string, error) { var output strings.Builder - if err := r.template.Execute(&output, newTitleTemplateInput(input)); err != nil { + if err := r.template.Execute(&output, newTemplateInput(input)); err != nil { return "", err } return html.UnescapeString(output.String()), nil } - -func titleTemplateFuncs() template.FuncMap { - return template.FuncMap{"elide": elide} -} - -func statusTemplateFuncs() template.FuncMap { - return template.FuncMap{ - "contextMeter": contextMeter, - "contextMeterCompact": contextMeterCompact, - "contextMeterMinimal": contextMeterMinimal, - "elide": elide, - } -} - -func elide(width int, value any) string { - if width <= 0 { - return "" - } - text := html.UnescapeString(stringifyTemplateValue(value)) - if ansi.StringWidth(text) <= width { - return html.EscapeString(text) - } - if width == 1 { - return "…" - } - return html.EscapeString(ansi.Truncate(text, width, "…")) -} - -func stringifyTemplateValue(value any) string { - switch value := value.(type) { - case templateText: - return string(value) - case string: - return value - default: - return "" - } -} diff --git a/cmd/mecatui/terminal_title_controller.go b/cmd/mecatui/terminal_title_controller.go index 4369e828b5..6f08bea60e 100644 --- a/cmd/mecatui/terminal_title_controller.go +++ b/cmd/mecatui/terminal_title_controller.go @@ -12,8 +12,9 @@ import ( const terminalTitleRunes = 160 -// terminalTitleController is the sole terminal-title output authority. Set runs -// during View; its mutex serializes OSC updates with Bubble Tea frame writes. +// terminalTitleController is the sole terminal-title output authority. Set stages +// the title rendered during View; Write serializes a changed OSC update immediately +// before the corresponding Bubble Tea frame. type terminalTitleController struct { mu sync.Mutex output io.Writer @@ -53,21 +54,21 @@ func (c *terminalTitleController) Set(input statusline.Input) { title = "DEBUG " + title } c.pending = sanitizeTerminalTitle(title) - c.flush() } -func (c *terminalTitleController) flush() { +func (c *terminalTitleController) flush() error { if c.pending == c.last || (c.pending == "" && !c.wrote) { - return + return nil } if _, err := io.WriteString(c.output, "\x1b]0;"+c.pending+"\a"); err != nil { c.writeErr = err - return + return err } c.last = c.pending if c.last != "" { c.wrote = true } + return nil } func (c *terminalTitleController) Write(p []byte) (int, error) { @@ -79,6 +80,9 @@ func (c *terminalTitleController) Write(p []byte) (int, error) { if c.writeErr != nil { return 0, c.writeErr } + if err := c.flush(); err != nil { + return 0, err + } return c.output.Write(p) } diff --git a/cmd/mecatui/terminal_title_controller_test.go b/cmd/mecatui/terminal_title_controller_test.go index d202071147..958d55edf0 100644 --- a/cmd/mecatui/terminal_title_controller_test.go +++ b/cmd/mecatui/terminal_title_controller_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "strings" "sync" "testing" @@ -15,16 +16,25 @@ func TestADR_0344_Scenario1_ControllerOwnsSerializedOSC0(t *testing.T) { controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}} · {{.MainAgent.State}}")) controller.Set(statusline.Input{Session: statusline.Session{Title: "first"}, MainAgent: statusline.MainAgent{State: "idle"}}) - if got := output.String(); got != "\x1b]0;first · idle\a" { - t.Fatalf("title change must be delivered without a Bubble Tea frame: %q", got) + if got := output.String(); got != "" { + t.Fatalf("View callback wrote before a Bubble Tea frame: %q", got) } if _, err := controller.Write([]byte("frame one")); err != nil { t.Fatalf("write first frame: %v", err) } + if got := output.String(); got != "\x1b]0;first · idle\aframe one" { + t.Fatalf("title was not flushed immediately before first frame: %q", got) + } controller.Set(statusline.Input{Session: statusline.Session{Title: "second"}, MainAgent: statusline.MainAgent{State: "thinking"}}) + if got := output.String(); got != "\x1b]0;first · idle\aframe one" { + t.Fatalf("changed title wrote before the next frame: %q", got) + } if _, err := controller.Write([]byte("frame two")); err != nil { t.Fatalf("write second frame: %v", err) } + if got := output.String(); got != "\x1b]0;first · idle\aframe one\x1b]0;second · thinking\aframe two" { + t.Fatalf("changed title was not flushed immediately before second frame: %q", got) + } var wg sync.WaitGroup for range 32 { @@ -117,15 +127,36 @@ func TestTerminalTitleCleanupAfterGracefulCancellation(t *testing.T) { var output bytes.Buffer controller := newTerminalTitleController(&output, true, mustTitleRenderer(t, "{{.Session.Title}}")) controller.Set(statusline.Input{Session: statusline.Session{Title: "running"}}) + if _, err := controller.Write([]byte("frame")); err != nil { + t.Fatalf("write running frame: %v", err) + } if err := closeTerminalTitle(controller); err != nil { t.Fatalf("close title after context cancellation: %v", err) } - if got := output.String(); got != "\x1b]0;running\a\x1b]0;\a" { - t.Fatalf("graceful cancellation cleanup = %q, want title followed by one clear", got) + if got := output.String(); got != "\x1b]0;running\aframe\x1b]0;\a" { + t.Fatalf("graceful cancellation cleanup = %q, want framed title followed by one clear", got) + } +} + +func TestTerminalTitleWriteErrorIsReturnedWithFrameWrite(t *testing.T) { + controller := newTerminalTitleController(failingTitleWriter{}, true, mustTitleRenderer(t, "{{.Session.Title}}")) + controller.Set(statusline.Input{Session: statusline.Session{Title: "running"}}) + + if _, err := controller.Write([]byte("frame")); !errors.Is(err, errTitleWrite) { + t.Fatalf("Write() error = %v, want title output error", err) + } + if err := controller.Close(); !errors.Is(err, errTitleWrite) { + t.Fatalf("Close() error = %v, want retained title output error", err) } } +var errTitleWrite = errors.New("title output failed") + +type failingTitleWriter struct{} + +func (failingTitleWriter) Write([]byte) (int, error) { return 0, errTitleWrite } + func TestADR_0344_Scenario1_SanitizesRenderedTitle(t *testing.T) { if got := sanitizeTerminalTitle("one\u0085two"); got != "onetwo" { t.Fatalf("terminal control must be stripped before whitespace collapse: %q", got) @@ -228,13 +259,15 @@ func TestADR_0344_Scenario3_LocalAndRemotePresentation(t *testing.T) { outputs = append(outputs, output.String()) } if outputs[0] != outputs[1] || strings.Contains(outputs[0], "/private/workspace") { - t.Fatalf("embedded=%q connect=%q; title must be connection-independent and path-free", outputs[0], outputs[1]) + t.Fatalf("embedded=%q connect=%q; title must be connection-independent and omit workspace path unless configured", outputs[0], outputs[1]) } - if got := renderTitle(t, "{{printf \"%+v\" .}}", input); strings.Contains(got, "/private/workspace") { - t.Fatalf("formatted title projection leaked workspace path: %q", got) + if got, want := renderTitle(t, "{{.Workspace.Path}}", input), "/private/workspace"; got != want { + t.Fatalf("explicit title workspace path = %q, want %q", got, want) } - if _, err := statusline.NewTitleRenderer("{{.Workspace.Path}}"); err == nil || !strings.Contains(err.Error(), "Path") { - t.Fatalf("title Workspace.Path projection error = %v, want unavailable-field error", err) + for _, source := range []string{"{{contextMeter .Context}}", "{{contextMeterCompact .Context}}", "{{contextMeterMinimal .Context}}"} { + if _, err := statusline.NewTitleRenderer(source); err == nil { + t.Fatalf("title template unexpectedly accepts status-only function in %q", source) + } } } diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 042518169e..1581c716d8 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -315,7 +315,7 @@ type Deps struct { NoMouse bool // TerminalTitle receives the display-safe snapshot during View. Its implementation - // writes only through Bubble Tea's configured output writer. + // stages the title; Bubble Tea's configured output writer delivers it with the frame. TerminalTitle func(statusline.Input) // Debug enables every client-side diagnostic surface. DebugMouse, DebugSteer, diff --git a/cmd/mecatui/ui/predictable_session_handles_test.go b/cmd/mecatui/ui/predictable_session_handles_test.go index 2fc70073a0..327cbe940c 100644 --- a/cmd/mecatui/ui/predictable_session_handles_test.go +++ b/cmd/mecatui/ui/predictable_session_handles_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "reflect" - "regexp" "strings" "testing" "time" @@ -74,34 +73,28 @@ func TestPredictableSessionHandles_Scenario1_FixedCollisionBehavior(t *testing.T } } -func TestPredictableSessionHandles_Scenario1_EscapedUTF8ControlsAndLeadingHyphen(t *testing.T) { +func TestPredictableSessionHandles_Scenario1_RawUTF8AndDisplayColumns(t *testing.T) { tests := []struct { name, id, want string }{ {"empty", "", ""}, - {"exactly twelve safe", "abcdefghijkl", "abcdefghijkl"}, + {"exactly twelve ASCII", "abcdefghijkl", "abcdefghijkl"}, {"normal cap", "abcdefghijklmnop", "abcdefghijkl"}, - {"leading hyphen", "-legacy-id", "%2Dlegacy-id"}, - {"non-leading hyphens", "a-b-c", "a-b-c"}, - {"escaped byte exactly fits", "123456789$tail", "123456789%24"}, - {"escaped byte cannot fit", "1234567890$tail", "1234567890"}, - {"shell significant", "abc$def;ghi", "abc%24def%3B"}, - {"controls", "a\x1b\n\tb", "a%1B%0A%09b"}, - {"multibyte utf8", "éclair", "%C3%A9clair"}, - {"multibyte byte atom boundary", "123456789é", "123456789%C3"}, - {"invalid utf8", string([]byte{0xff, 'a', 'b', 'c'}), ""}, - {"literal percent escaped", "100% ready", "100%25%20rea"}, + {"leading hyphen", "-legacy-id", "-legacy-id"}, + {"punctuation", "abc$def;ghi", "abc$def;ghi"}, + {"controls removed", "a\x1b\n\tb", "ab"}, + {"format characters removed", "safe\u202ehandle", "safehandle"}, + {"multibyte UTF-8", "éclair", "éclair"}, + {"multibyte rune boundary", "1234567890雪x", "1234567890雪"}, + {"wide rune columns", "12345678901雪x", "12345678901"}, + {"invalid UTF-8", string([]byte{0xff, 'a', 'b', 'c'}), ""}, + {"literal percent", "100% ready", "100% ready"}, } - allowed := regexp.MustCompile(`^[A-Za-z0-9._%-]*$`) for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := client.SessionHandle(tc.id) - if got != tc.want { + if got := client.SessionHandle(tc.id); got != tc.want { t.Fatalf("SessionHandle(%q) = %q, want %q", tc.id, got, tc.want) } - if len(got) > client.SessionHandleWidth || !allowed.MatchString(got) || strings.HasPrefix(got, "-") { - t.Fatalf("handle %q violates fixed ASCII grammar", got) - } }) } } @@ -119,6 +112,7 @@ func testPredictableSessionHandle(t *testing.T, checks predictableSessionHandleC t.Helper() const id = "legacy\x1b/$雪-session" want := client.SessionHandle(id) + renderedHandle := want if checks&checkHandlePresentation != 0 { m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: context.Background(), DebugTarget: id}) @@ -133,8 +127,8 @@ func testPredictableSessionHandle(t *testing.T, checks predictableSessionHandleC st.syncFilter() presentations["sessions"] = stripANSIstr(renderSessionsPanel(testTheme(), st, client.Capabilities{}, helpKeys{}, 100, 30, "")) for name, rendered := range presentations { - if (name != "header" && !strings.Contains(rendered, want)) || strings.Contains(rendered, "#"+want) || strings.Contains(rendered, "\x1b") { - t.Fatalf("%s does not use terminal-safe shared handle %q: %q", name, want, rendered) + if (name != "header" && !strings.Contains(rendered, renderedHandle)) || strings.Contains(rendered, "#"+renderedHandle) || strings.Contains(rendered, "\x1b") { + t.Fatalf("%s does not use terminal-safe shared handle %q: %q", name, renderedHandle, rendered) } } } @@ -153,7 +147,7 @@ func testPredictableSessionHandle(t *testing.T, checks predictableSessionHandleC if err != nil { t.Fatal(err) } - if !strings.Contains(string(wire), `"Handle":"`+want+`"`) || strings.Contains(string(wire), `"Digest"`) { + if !strings.Contains(string(wire), `"Handle":`) || strings.Contains(string(wire), `"Digest"`) { t.Fatalf("status command JSON does not expose only Session.Handle: %s", wire) } source := statusline.NewDefaultSource(0) diff --git a/go.mod b/go.mod index 2072f49ce7..22789086f2 100644 --- a/go.mod +++ b/go.mod @@ -208,7 +208,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mattn/go-isatty v0.0.21 // indirect - github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mattn/goveralls v0.0.12 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index 31fafcc094..bdb9b1a047 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -42,13 +42,10 @@ mecatui: final-session-id="01JOPAQUESESSIONID" ## Inspect the active session during a run -Run `/session` at any point after a session is bound, including while the agent -is responding or waiting on a tool. Mecatui opens the read-only details overlay -with the full session ID. Press `c` to copy that exact ID, then press `esc` to -close the overlay and return focus to the conversation. Opening the overlay -does not cancel, pause, or steer the run. - -When no session is bound, `/session` keeps the `no active session` response. +Run `/session` after a session is bound to open its read-only details overlay, +including while the agent is responding or waiting on a tool. Press `c` to copy +the full session ID. Press `esc` to close the overlay and return focus to the +conversation. Opening the overlay does not cancel, pause, or steer the run. ## Browse and maintain stored sessions @@ -155,8 +152,8 @@ mecatui debug 01JOPAQUESESSIONID \ mecatui debug 01JOPAQUESESSIONID --debug-mcp github ``` -`TARGET` can be the full ID or the displayed 12-column handle. If a handle is -ambiguous, copy the full ID from `/session` and try again. Use the embedded +`TARGET` can be the full ID or the short displayed ID. If the displayed ID is +ambiguous, open `/session`, copy the full ID, and try again. Use the embedded command for an embedded store and `connect ADDRESS` for the server that owns the target. The optional `--prompt` value replaces the default diagnosis objective. diff --git a/user-docs/mecatui/status-line.md b/user-docs/mecatui/status-line.md index f47810b2d1..bd058e9d07 100644 --- a/user-docs/mecatui/status-line.md +++ b/user-docs/mecatui/status-line.md @@ -66,10 +66,18 @@ time value and supports `{{.Clock.Now.Format "15:04"}}`. The `Human` members are preformatted display values; use each `Raw` member when a template needs an exact count. -### Context meter functions +### Common template functions -Templates provide three functions that render the current context use as -StatusML. Use the function that matches the template variant: +Status and terminal-title templates support `elide WIDTH VALUE`. A non-positive +width produces an empty value, a fitting value is unchanged, width `1` produces +`…`, and a wider value is shortened to the widest fitting prefix followed by +`…`. + +### Status-only context meter functions + +Status templates provide three functions that render the current context use as +StatusML. They are not available to terminal-title templates. Use the function +that matches the status template variant: - `contextMeter .Context` for `full` - `contextMeterCompact .Context` for `compact` @@ -101,7 +109,7 @@ refreshes it. |`Server.DisplayTarget`|string|Credential-free target shown by the client.| |`Server.ConnectionMode`|string|`embedded`, `connect`, or empty while unknown.| |`Session.Title`|string|Optional display title.| -|`Session.Handle`|string|Fixed 12-column ordinary session handle available to custom status and terminal-title templates: safe `[A-Za-z0-9._-]` bytes are literal except that a leading `-` is encoded as `%2D`; other UTF-8 bytes are uppercase `%HH`, and only complete atoms that fit are included. It has no leading `#` and replaces the v1 `Session.Digest` field in protocol v2; no digest alias is emitted.| +|`Session.Handle`|string|Short displayed session ID, available to custom status and terminal-title templates. Use `/session` to copy the full ID.| |`Session.Mode`|string|Active or pending permission mode used by the shipped header.| |`Session.ReasoningEffort`|string|`low`, `medium`, `high`, `xhigh`, `max`, or empty.| |`Model.ProviderID`, `Model.ID`, `Model.DisplayName`, `Model.Route`|strings|Provider/model routing identifiers, display label, and observed downstream route.| @@ -112,7 +120,7 @@ refreshes it. |`Context.Percent`|integer|`Used.Raw / Window.Raw` as an integer percentage, or `0` when unknown.| |`Workspace.Location`|string|`local`, `remote`, or `unknown`.| |`Workspace.Name`|string|Provider-supplied workspace display metadata. It is not a directory basename or a usable path.| -|`Workspace.Path`|string|Exact local root returned by the privileged local-context RPC. It is available to status templates through their StatusML-escaped projection and to a configured direct local status command. It is empty for remote, untrusted, no-FS, unavailable, and otherwise ineligible sessions.| +|`Workspace.Path`|string|Exact local root returned by the privileged local-context RPC. It is available to templates through their escaped projection and to a configured direct local status command. It is empty for remote, untrusted, no-FS, unavailable, and otherwise ineligible sessions.| |`Terminal.Rows`, `Terminal.Cols`|integers|Measured terminal dimensions.| |`Terminal.HeaderAvailCols`, `Terminal.FooterAvailCols`|integers|Columns remaining after the client reserves mandatory header and footer lanes.| |`MainAgent.State`|string|`connecting`, `idle`, `thinking`, `running_tool`, `awaiting_approval`, `completed`, `failed`, or `cancelled`.| @@ -318,10 +326,9 @@ the ## Customize the terminal title -`mecatui` owns the terminal title and sends it through the same serialized output -path as the interface. It emits OSC 0 when the rendered title changes and clears -it on a clean exit. The title is plain text, so StatusML tags and command output -never become part of it. +`mecatui` updates the terminal title when its rendered value changes and clears +it on a clean exit. Title templates produce plain text and are independent of +StatusML and status commands. Configure the title in the client-owned `$XDG_CONFIG_HOME/mecatui/settings.yaml` file (normally @@ -335,15 +342,10 @@ terminal_title: The setting applies to embedded and connected clients. The shipped template uses the session title and agent state, and falls back to `mecatui` before a -session title exists. It does not include the session handle. Add -`.Session.Handle` when you want a handle in the title. The title template gets -the same display-safe input as status templates, including `Session`, `Model`, -`Context`, `Usage`, `Workspace`, `Terminal`, `MainAgent`, `Delegation`, and -`Clock` values. The title-specific projection excludes `Workspace.Path`; exact -local roots remain available only to status templates and direct status commands. -It also supports `elide WIDTH VALUE`: a non-positive width is -empty, a fitting value is unchanged, width `1` is `…`, and wider values are -truncated to the widest prefix that fits plus `…`. +session title exists. It does not include the session handle; add +`.Session.Handle` when you want one. Title templates use the shared template +input described in [Status input reference](#status-input-reference), including +`Workspace.Path`, and support the common `elide` function. Title writes follow this precedence: @@ -355,14 +357,12 @@ Title writes follow this precedence: 3. When neither explicit control applies, `terminal_title.enabled` controls the feature. The default is enabled. -The renderer removes terminal controls, collapses whitespace to single spaces, -and bounds the title before constructing OSC 0. A terminal emulator or -multiplexer decides whether and where to show OSC 0, so a tab or pane label can -remain unchanged even when `mecatui` emits a title. Disable titles when the -terminal environment owns title presentation or filters OSC sequences. Invalid -YAML, unknown fields, and invalid title templates stop startup with an error -that identifies `terminal_title` or `terminal_title.template`. Restart -`mecatui` after changing this file; settings are not hot-reloaded. +A terminal emulator or multiplexer decides whether and where to show the title, +so a tab or pane label can remain unchanged. Disable titles when the terminal +environment owns title presentation. Invalid YAML, unknown fields, and invalid +title templates stop startup with an error that identifies `terminal_title` or +`terminal_title.template`. Restart `mecatui` after changing this file; settings +are not hot-reloaded. ## Next steps