Skip to content

Commit e7c2fb8

Browse files
feat(tui): add accessibility controls
1 parent c820d45 commit e7c2fb8

15 files changed

Lines changed: 206 additions & 58 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Maestro is an AI code review and repository analysis assistant built on top of `
8787
- **Coding-Agent Workflow**: Natural-language prompts can inspect and edit the authoritative workspace you launched Maestro from while typed run/turn/tool events update progress; shell verification is explicit opt-in.
8888
- **Slash-Command Workflow**: `/ask`, `/review`, session, and subagent commands remain explicit specialist paths. `/review` invokes the specialized review pipeline through a dspy-go `agents.Agent` adapter, and coding sessions can delegate explicit PR-review requests through the same `review_pull_request` subagent tool.
8989
- **Unified Review Component**: inline `/review` results and standalone `RunReviewTUI` share file grouping, flattened selection, severity filters, list/detail rendering, and colorblind-safe severity shapes. Standalone posting confirmation remains available when its host supplies a posting callback; host-specific quit/status semantics stay at the wrapper boundary.
90+
- **Accessible Presentation**: `--high-contrast` or `MAESTRO_THEME=high-contrast` increases text and border contrast; `--reduce-motion` or `MAESTRO_REDUCE_MOTION=1` replaces animated status frames with a static label. Standard `NO_COLOR` disables color without enabling reduced motion.
9091
- **Responsive Agent Context**: the header identifies the authoritative workspace and active model. At 80+ columns, active run/review context can move into an adaptive rail; narrow panes retain a single chronological transcript. Ctrl+\\ toggles the rail without discarding its state.
9192
- **Adaptive Composer**: multiline prompts grow the editor to at most 30% of the terminal or 10 rows and show line/character counts while preserving a usable conversation region.
9293
- **Readable Responses**: assistant output renders Markdown, links, inline code, and fenced code blocks with Maestro's dark/coral syntax palette and width-aware reflow.

‎dev-notes/tui-accessibility.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# TUI accessibility controls
2+
3+
Phase 6 adds explicit presentation controls without changing Maestro's dark/coral default identity.
4+
5+
- `--high-contrast` and `MAESTRO_THEME=high-contrast` select stronger muted-text and border colors.
6+
- `--reduce-motion` and `MAESTRO_REDUCE_MOTION=1` replace the animated spinner with a static `[working]` label while retaining periodic elapsed-time updates.
7+
- `NO_COLOR` selects a color-free theme and leaves reduced motion independent. Structure, text emphasis, review severity shapes, and status labels remain available without hue.
8+
9+
These settings are resolved when the canonical `MaestroModel` is constructed, so embedders receive the same environment behavior as the CLI. Tests cover color-free Markdown, environment theme selection, and stable reduced-motion frames.
10+
11+
Run:
12+
13+
```sh
14+
GOWORK=off go test ./terminal
15+
GOWORK=off go test -race ./terminal
16+
```

‎docs/coding_session.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Inspect the failing tests, implement the smallest fix, and run the focused tests
6161

6262
In interactive mode, the workspace is the current working directory you launched Maestro from. The responsive header labels that workspace and the active provider/model; coding tools and any file-tree view should all refer to the same root.
6363

64+
For accessible presentation, use `--high-contrast` (or `MAESTRO_THEME=high-contrast`) for stronger text and border contrast, and `--reduce-motion` (or `MAESTRO_REDUCE_MOTION=1`) for static progress indicators. `NO_COLOR` independently removes colors while preserving labels, severity shapes, emphasis, and layout; it does not imply reduced motion.
65+
6466
Prompt controls:
6567

6668
- **Enter** runs the current task.

‎main.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ type config struct {
112112
verbose bool
113113
verifyOnly bool
114114
allowCodingBash bool
115+
highContrast bool
116+
reduceMotion bool
115117
modelProvider string
116118
modelName string
117119
modelConfig string // For additional model-specific configuration
@@ -219,6 +221,12 @@ Available slash commands in interactive mode:
219221
/ask <QUESTION> - Ask a question about the repository
220222
/exit or /quit - Exit the application`,
221223
RunE: func(cmd *cobra.Command, args []string) error {
224+
if cfg.highContrast {
225+
_ = os.Setenv("MAESTRO_THEME", "high-contrast")
226+
}
227+
if cfg.reduceMotion {
228+
_ = os.Setenv("MAESTRO_REDUCE_MOTION", "1")
229+
}
222230
if cmd.Flags().Changed("model") {
223231
modelStr, _ := cmd.Flags().GetString("model")
224232
provider, name, modelCfg := util.ParseModelString(modelStr)
@@ -253,6 +261,8 @@ Available slash commands in interactive mode:
253261
rootCmd.PersistentFlags().BoolVar(&cfg.verbose, "verbose", false, "Enable verbose logging")
254262
rootCmd.PersistentFlags().BoolVar(&cfg.verifyOnly, "verify-only", false, "Only verify token permissions")
255263
rootCmd.PersistentFlags().BoolVar(&cfg.allowCodingBash, "allow-coding-bash", false, "Allow the coding agent to run unrestricted shell commands in the workspace")
264+
rootCmd.PersistentFlags().BoolVar(&cfg.highContrast, "high-contrast", false, "Use the high-contrast TUI theme")
265+
rootCmd.PersistentFlags().BoolVar(&cfg.reduceMotion, "reduce-motion", false, "Replace TUI animation with static status indicators")
256266

257267
rootCmd.PersistentFlags().BoolP("interactive", "i", false, "Run in interactive mode")
258268

@@ -957,6 +967,8 @@ func runModernUI(cfg *config) error {
957967
Verbose: cfg.verbose,
958968
IndexWorkers: cfg.indexWorkers,
959969
ReviewWorkers: cfg.reviewWorkers,
970+
HighContrast: cfg.highContrast,
971+
ReduceMotion: cfg.reduceMotion,
960972
}
961973

962974
// Create service adapter for TUI

‎terminal/accessibility_test.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package terminal
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"charm.land/lipgloss/v2"
8+
)
9+
10+
func TestNoColorThemeAndMarkdownContainNoANSI(t *testing.T) {
11+
t.Setenv("NO_COLOR", "1")
12+
model := NewMaestroModel(&MaestroConfig{}, NewNoOpBackend("owner", "repo"))
13+
if _, ok := model.theme.Accent.(lipgloss.NoColor); !ok {
14+
t.Fatalf("accent color type = %T, want lipgloss.NoColor", model.theme.Accent)
15+
}
16+
message := Message{Role: "assistant", Content: "# Heading\n\n`code` and **bold**"}
17+
rendered := model.renderAssistantMessage(&message, 60)
18+
if strings.Contains(rendered, "\x1b[") {
19+
t.Fatalf("NO_COLOR markdown contains ANSI escapes: %q", rendered)
20+
}
21+
if !strings.Contains(rendered, "**bold**") {
22+
t.Fatalf("NO_COLOR markdown lost textual emphasis: %q", rendered)
23+
}
24+
}
25+
26+
func TestHighContrastThemeCanBeSelectedFromEnvironment(t *testing.T) {
27+
t.Setenv("NO_COLOR", "")
28+
t.Setenv("MAESTRO_THEME", "high-contrast")
29+
model := NewMaestroModel(&MaestroConfig{}, NewNoOpBackend("owner", "repo"))
30+
if model.theme.TextMuted == ClaudeCodeTheme().TextMuted || model.theme.Comment != model.theme.TextMuted {
31+
t.Fatal("high-contrast theme retained a default low-contrast semantic color")
32+
}
33+
styles := maestroMarkdownStyles(model.theme)
34+
if styles.CodeBlock.Chroma == nil || styles.CodeBlock.Chroma.Comment.Color == nil ||
35+
*styles.CodeBlock.Chroma.Comment.Color != *markdownColor(model.theme.Comment) {
36+
t.Fatal("Markdown code comments do not use the selected theme")
37+
}
38+
}
39+
40+
func TestReducedMotionUsesStaticProgressIndicator(t *testing.T) {
41+
progress := NewProgressModel(ClaudeCodeTheme())
42+
progress.SetReducedMotion(true)
43+
progress.message = "Testing"
44+
progress.state = ProgressWorking
45+
first := progress.renderWorking()
46+
progress.frame = 7
47+
second := progress.renderWorking()
48+
if first != second || !strings.Contains(first, "[working]") {
49+
t.Fatalf("reduced-motion progress changed frames: %q != %q", first, second)
50+
}
51+
}

‎terminal/backend.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ type MaestroConfig struct {
8282
Verbose bool
8383
IndexWorkers int
8484
ReviewWorkers int
85+
HighContrast bool
86+
ReduceMotion bool
8587
}
8688

8789
// NoOpBackend is a placeholder backend for testing.

‎terminal/commands.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ func (cp *CommandPaletteModel) renderCommandItem(cmd Command, selected bool) str
234234

235235
if selected {
236236
style = lipgloss.NewStyle().
237-
Background(lipgloss.Color("#3A3C55")).
237+
Background(cp.theme.Surface).
238238
Foreground(cp.theme.Accent).
239239
Bold(true)
240240
} else {

‎terminal/maestro_model.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ type SessionMutationMsg struct {
119119

120120
// NewMaestroModel creates a new root TUI model.
121121
func NewMaestroModel(cfg *MaestroConfig, backend MaestroBackend) *MaestroModel {
122-
theme := ClaudeCodeTheme()
122+
theme := ResolveTheme(cfg != nil && cfg.HighContrast)
123123
styles := theme.CreateStyles()
124124

125125
vp := viewport.New()
@@ -150,6 +150,11 @@ func NewMaestroModel(cfg *MaestroConfig, backend MaestroBackend) *MaestroModel {
150150

151151
// Create input model and hide optional capabilities the backend does not expose.
152152
m.inputModel = NewInputModel(theme, m.handleCommand, m.handleQuestion)
153+
reduceMotion := cfg != nil && cfg.ReduceMotion
154+
if value := strings.TrimSpace(os.Getenv("MAESTRO_REDUCE_MOTION")); value != "" && value != "0" {
155+
reduceMotion = true
156+
}
157+
m.progressModel.SetReducedMotion(reduceMotion)
153158
if _, ok := backend.(modelSelectionProvider); !ok {
154159
m.inputModel.removeCommand("model")
155160
m.commandPalette.removeCommand("model")
@@ -1495,11 +1500,11 @@ func (m *MaestroModel) renderMessages() {
14951500
case "system":
14961501
// System messages: warning style
14971502
icon := lipgloss.NewStyle().
1498-
Foreground(lipgloss.Color("#FF6B6B")).
1503+
Foreground(m.theme.LogoPrimary).
14991504
Bold(true).
15001505
Render("⚠ ")
15011506
content := lipgloss.NewStyle().
1502-
Foreground(lipgloss.Color("#FF6B6B")).
1507+
Foreground(m.theme.LogoPrimary).
15031508
Render(msg.Content)
15041509
sb.WriteString(icon + content)
15051510
}

‎terminal/markdown.go‎

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package terminal
22

33
import (
4+
"fmt"
5+
"image/color"
46
"strings"
57

68
"charm.land/glamour/v2"
79
glamouransi "charm.land/glamour/v2/ansi"
810
glamourstyles "charm.land/glamour/v2/styles"
11+
"charm.land/lipgloss/v2"
912
)
1013

1114
func messageWidthBucket(width int) int {
@@ -23,7 +26,7 @@ func (m *MaestroModel) renderAssistantMessage(message *Message, width int) strin
2326
}
2427

2528
message.renderCount++
26-
styles := maestroMarkdownStyles()
29+
styles := maestroMarkdownStyles(m.theme)
2730
renderer, err := glamour.NewTermRenderer(
2831
glamour.WithStyles(styles),
2932
glamour.WithWordWrap(bucket),
@@ -44,12 +47,18 @@ func (m *MaestroModel) renderAssistantMessage(message *Message, width int) strin
4447
return message.Rendered
4548
}
4649

47-
func maestroMarkdownStyles() glamouransi.StyleConfig {
48-
styles := glamourstyles.DarkStyleConfig
50+
func maestroMarkdownStyles(theme *Theme) glamouransi.StyleConfig {
4951
zero := uint(0)
52+
if _, noColor := theme.TextPrimary.(lipgloss.NoColor); noColor {
53+
styles := glamourstyles.NoTTYStyleConfig
54+
styles.Document.Margin = &zero
55+
styles.CodeBlock.Margin = &zero
56+
return styles
57+
}
58+
styles := glamourstyles.DarkStyleConfig
5059
styles.Document.Margin = &zero
51-
styles.Document.Color = stringPointer("#E8E9F3")
52-
styles.Heading.Color = stringPointer("#E8985A")
60+
styles.Document.Color = markdownColor(theme.TextPrimary)
61+
styles.Heading.Color = markdownColor(theme.LogoPrimary)
5362
for _, heading := range []*glamouransi.StyleBlock{
5463
&styles.H1,
5564
&styles.H2,
@@ -58,28 +67,30 @@ func maestroMarkdownStyles() glamouransi.StyleConfig {
5867
&styles.H5,
5968
&styles.H6,
6069
} {
61-
heading.Color = stringPointer("#E8985A")
70+
heading.Color = markdownColor(theme.LogoPrimary)
6271
heading.BackgroundColor = nil
6372
}
64-
styles.Link.Color = stringPointer("#00D9FF")
65-
styles.LinkText.Color = stringPointer("#00D9FF")
66-
styles.Code.Color = stringPointer("#D4D4E5")
67-
styles.Code.BackgroundColor = stringPointer("#2B2D42")
73+
styles.Link.Color = markdownColor(theme.Accent)
74+
styles.LinkText.Color = markdownColor(theme.Accent)
75+
styles.Code.Color = markdownColor(theme.Code)
76+
styles.Code.BackgroundColor = markdownColor(theme.Surface)
6877
styles.CodeBlock.Margin = &zero
69-
styles.CodeBlock.Color = stringPointer("#D4D4E5")
78+
styles.CodeBlock.Color = markdownColor(theme.Code)
7079
if styles.CodeBlock.Chroma != nil {
7180
chroma := *styles.CodeBlock.Chroma
7281
styles.CodeBlock.Chroma = &chroma
73-
styles.CodeBlock.Chroma.Text.Color = stringPointer("#D4D4E5")
74-
styles.CodeBlock.Chroma.Comment.Color = stringPointer("#6B6B7E")
75-
styles.CodeBlock.Chroma.Keyword.Color = stringPointer("#B185F7")
76-
styles.CodeBlock.Chroma.KeywordReserved.Color = stringPointer("#B185F7")
77-
styles.CodeBlock.Chroma.KeywordType.Color = stringPointer("#B185F7")
78-
styles.CodeBlock.Chroma.LiteralString.Color = stringPointer("#7FE9DE")
82+
styles.CodeBlock.Chroma.Text.Color = markdownColor(theme.Code)
83+
styles.CodeBlock.Chroma.Comment.Color = markdownColor(theme.Comment)
84+
styles.CodeBlock.Chroma.Keyword.Color = markdownColor(theme.Keyword)
85+
styles.CodeBlock.Chroma.KeywordReserved.Color = markdownColor(theme.Keyword)
86+
styles.CodeBlock.Chroma.KeywordType.Color = markdownColor(theme.Keyword)
87+
styles.CodeBlock.Chroma.LiteralString.Color = markdownColor(theme.String)
7988
}
8089
return styles
8190
}
8291

83-
func stringPointer(value string) *string {
84-
return &value
92+
func markdownColor(value color.Color) *string {
93+
r, g, b, _ := value.RGBA()
94+
hex := fmt.Sprintf("#%02X%02X%02X", uint8(r>>8), uint8(g>>8), uint8(b>>8))
95+
return &hex
8596
}

‎terminal/markdown_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
)
1010

1111
func TestRenderAssistantMessageRendersMarkdown(t *testing.T) {
12+
t.Setenv("NO_COLOR", "")
13+
t.Setenv("MAESTRO_THEME", "")
14+
t.Setenv("MAESTRO_REDUCE_MOTION", "")
1215
model := NewMaestroModel(&MaestroConfig{}, NewNoOpBackend("owner", "repo"))
1316
message := Message{Content: "# Result\n\nUse **care** and `go test`."}
1417

0 commit comments

Comments
 (0)