diff --git a/cmd/late/main.go b/cmd/late/main.go index bb16d27..61072ec 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -439,6 +439,11 @@ func main() { } resolvedSubagentConfig := appconfig.ResolveSubagentSettings(appConfig, resolvedOpenAIConfig) + // Resolve the startup state of the todos side pane. The panel is open + // by default; config.json "show-todo-pane": false starts with it + // closed. Width handling happens in the TUI (see model.ShowTodoPane). + showTodoPane := appConfig.ResolveShowTodoPane() + // Validate --suppress-thinking-words: only allowed in homogeneous setups if err := validateSuppressThinkingWords(*suppressThinkingWordsReq, resolvedClientConfig.Model, resolvedSubagentConfig.Model, appConfig); err != nil { fmt.Fprintf(os.Stderr, "Error: --suppress-thinking-words is currently only supported when orchestrator and subagents use the same model: %v\n", err) @@ -663,6 +668,14 @@ func main() { model.SubagentInfo = resolvedSubagentConfig.Model } model.ShowCWD = *showCWDReq + // Set ShowTodoPane BEFORE the SetSize calls below: SetSize -> updateLayout + // reserves the side-pane width for terminals >= 85 cols and silently + // closes the pane again below that threshold — the same guard as the + // /todos command, without a startup toast. Users on narrow terminals + // can open it with /todos later, which shows the standard toast. If the + // size cannot be detected here, the initial WindowSizeMsg runs the same + // updateLayout path. + model.ShowTodoPane = showTodoPane model.LazyHistory = true pOpts := []tea.ProgramOption{ diff --git a/internal/config/config.go b/internal/config/config.go index 4b0463f..4e280e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,12 @@ type Config struct { SkillsDir string `json:"skills_dir,omitempty"` + // ShowTodoPane controls whether the todos side pane starts open. + // The todos panel is open by default; set false to start with it + // closed. Terminals narrower than 85 columns always start with the + // pane closed; it can be opened later with /todos. + ShowTodoPane *bool `json:"show-todo-pane,omitempty"` + Theme string `json:"theme,omitempty"` Models []ModelSetting `json:"models,omitempty"` AgentModels map[string]string `json:"agent_models,omitempty"` @@ -292,6 +298,20 @@ func ResolvePermissionMode(cfg *Config, askFlag, unsupervisedFlag bool) (mode st return PermissionModeAskForUserApproval, "", nil } +// ResolveShowTodoPane returns whether the todos side pane should be open +// when the TUI starts. The todos panel is open by default; set +// "show-todo-pane": false in config.json to start with it closed. +// An absent entry (nil pointer) resolves to true, an explicit false to +// false, and an explicit true to true. A nil config also resolves to the +// open default. Terminal width is not considered here: the TUI silently +// keeps the pane closed below 85 columns. +func (cfg *Config) ResolveShowTodoPane() bool { + if cfg == nil || cfg.ShowTodoPane == nil { + return true + } + return *cfg.ShowTodoPane +} + func nonEmptyEnv(lookup EnvLookup, key string) (string, bool) { if lookup == nil { return "", false diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5d4f8c1..e8a7e14 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -638,6 +638,36 @@ func TestConfig_GetModelForAgent(t *testing.T) { } } +func TestConfig_ResolveShowTodoPane(t *testing.T) { + closed := false + open := true + + var absentEntry Config + if err := json.Unmarshal([]byte(`{"theme":"late"}`), &absentEntry); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + tests := []struct { + name string + cfg *Config + want bool + }{ + {"nil config defaults to open", nil, true}, + {"absent show-todo-pane entry defaults to open", &absentEntry, true}, + {"zero-value config defaults to open", &Config{}, true}, + {"explicit false starts with the pane closed", &Config{ShowTodoPane: &closed}, false}, + {"explicit true keeps the pane open", &Config{ShowTodoPane: &open}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.ResolveShowTodoPane(); got != tt.want { + t.Errorf("ResolveShowTodoPane() = %v, want %v", got, tt.want) + } + }) + } +} + func TestConfig_GetModelForAgentUsesStableID(t *testing.T) { cfg := &Config{ Models: []ModelSetting{ diff --git a/internal/tui/styles.go b/internal/tui/styles.go index cbb4c00..d8b5efc 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -24,6 +24,10 @@ var ( borderColor = lipgloss.Color("#1C1F26") // Hairline Divider activeBorder = lipgloss.Color("#2D323E") // Focused / Active Border + // Todo pane focus surface. Elevated so the pane visibly changes color + // while focused (userMsgBg aliases appBgColor and would be invisible). + todoFocusedBg = chipBgColor + // Centralized Border Styles & Colors boxBorderStyle = lipgloss.RoundedBorder() modalBorderColor = secondaryColor // Primary border color for dialogs & overlays (/help, /themes, commit detail) diff --git a/internal/tui/todo_pane_test.go b/internal/tui/todo_pane_test.go index ec4c354..9ad227d 100644 --- a/internal/tui/todo_pane_test.go +++ b/internal/tui/todo_pane_test.go @@ -5,7 +5,13 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" + + "late/internal/client" "late/internal/common" + "late/internal/orchestrator" + "late/internal/session" + "late/internal/tool" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -111,3 +117,178 @@ func TestTodoMaxScrollOffset(t *testing.T) { t.Fatalf("max scroll offset = %d, want %d", got, want) } } + +// newTodoPaneStartupModel builds a Model whose root registers a list_todos +// provider. Callers mirror the startup sequence in cmd/late/main.go: set +// ShowTodoPane from the resolved "show-todo-pane" config BEFORE the initial +// SetSize, whose updateLayout reserves the side-pane width at >= 85 columns +// and silently closes the pane again below that threshold. +func newTodoPaneStartupModel(t *testing.T, todos []tool.Todo) Model { + t.Helper() + // A real (offline) client keeps BaseOrchestrator.MaxTokens() happy while + // buildScreen renders the status bar; session.New(nil, ...) would leave + // Client() nil and panic on the first View(). + sess := session.New(client.NewClient(client.Config{}), "", nil, "", false) + sess.Registry.Register(tool.ListTodosTool{Todos: &todos}) + root := orchestrator.NewBaseOrchestrator(common.MainAgentID, sess, nil, 0) + return NewModel(root, nil, nil) +} + +func TestTodoPaneOpenByDefaultAtStartup(t *testing.T) { + todos := []tool.Todo{ + {Text: "Scaffold the parser"}, + {Text: "Wire the CLI flags", Done: true}, + } + m := newTodoPaneStartupModel(t, todos) + m.ShowTodoPane = true // resolved default: show-todo-pane absent -> open + m.SetSize(100, 30) + + if !m.ShowTodoPane { + t.Fatal("todos pane should stay open at 100 cols") + } + if got := m.Viewport.Width(); got != 100-todoPaneWidth { + t.Fatalf("viewport width = %d, want %d (todos pane width reserved)", got, 100-todoPaneWidth) + } + screen := ansi.Strip(m.View().Content) + if !strings.Contains(screen, "Todos") { + t.Fatalf("todos pane header missing from startup screen: %q", screen) + } + if !strings.Contains(screen, "Scaffold the parser") { + t.Fatalf("todo content missing from startup screen: %q", screen) + } +} + +func TestTodoCommandTogglesStartupPaneOff(t *testing.T) { + m := newTodoPaneStartupModel(t, nil) + m.ShowTodoPane = true + m.SetSize(100, 30) + + m.Input.SetValue("/todos") + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = updated.(Model) + + if m.ShowTodoPane { + t.Fatal("/todos should close the pane that starts open by default") + } + if got := m.Viewport.Width(); got != 100 { + t.Fatalf("viewport width = %d, want full %d after closing the pane", got, 100) + } + if screen := ansi.Strip(m.View().Content); strings.Contains(screen, "No todos created yet.") { + t.Fatal("todos pane still rendered after /todos") + } +} + +func TestTodoPaneStartupWidthGuardIsSilent(t *testing.T) { + m := newTodoPaneStartupModel(t, nil) + // Resolved default is open, but the terminal is narrower than the + // 85-column pane threshold: SetSize -> updateLayout closes the pane + // silently instead of toasting at startup (unlike /todos on a narrow + // terminal, which toasts). The user can /todos it open later. + m.ShowTodoPane = true + m.SetSize(84, 30) + + if m.ShowTodoPane { + t.Fatal("todos pane must start closed below 85 cols") + } + if m.ToastMessage != "" { + t.Fatalf("startup width guard must be silent, got toast %q", m.ToastMessage) + } + if got := m.Viewport.Width(); got != 84 { + t.Fatalf("viewport width = %d, want full %d", got, 84) + } + if screen := ansi.Strip(m.View().Content); strings.Contains(screen, "No todos created yet.") { + t.Fatalf("todos pane rendered despite narrow terminal: %q", screen) + } +} + +// newFocusedTodoPaneModel returns a model with the todo pane open and focused, +// plus the same model in the unfocused state, at a terminal size where the +// pane is reserved (>= 85 columns). +func newFocusedTodoPaneModel(t *testing.T, todos []tool.Todo) (focused, unfocused Model, focusedView, unfocusedView string) { + t.Helper() + m := newTodoPaneStartupModel(t, todos) + m.ShowTodoPane = true + m.SetSize(100, 30) + + unfocused = m + unfocusedView = m.todoPaneView(m.Viewport.Height()) + + m.TodoPaneFocused = true + focused = m + focusedView = m.todoPaneView(m.Viewport.Height()) + + return focused, unfocused, focusedView, unfocusedView +} + +func TestTodoPaneFocusedRenderDiffersFromUnfocused(t *testing.T) { + _, _, focused, unfocused := newFocusedTodoPaneModel(t, []tool.Todo{{Text: "Scaffold the parser"}}) + + if focused == unfocused { + t.Fatal("focused todo pane render must differ from the unfocused render") + } +} + +func TestTodoPaneFocusMarker(t *testing.T) { + _, _, focused, unfocused := newFocusedTodoPaneModel(t, []tool.Todo{{Text: "Scaffold the parser"}}) + + // (b) focused render carries the explicit focus/unfocus hint. + if got := ansi.Strip(focused); !strings.Contains(got, "[focused · esc to unfocus]") { + t.Fatalf("focused pane missing [focused · esc to unfocus] marker: %q", got) + } + // (c) unfocused render carries no focus marker. + if got := ansi.Strip(unfocused); strings.Contains(got, "[focused") { + t.Fatalf("unfocused pane must not contain a [focused marker: %q", got) + } +} + +// Truecolor SGR parameter fragments for the theme colors the pane switches +// between (same hardcoded-fragment pattern as theme_test.go): +// appBgColor #0B0C0E, todoFocusedBg #1B1E28, primaryColor #E5A85C. +const ( + todoTestAppBgSgrParams = "48;2;11;12;14" + todoTestFocusBgSgrParams = "48;2;27;30;40" + todoTestBorderSgrParams = "38;2;229;168;92" +) + +func TestTodoPaneFocusedBackgroundAndBorder(t *testing.T) { + _, _, focused, unfocused := newFocusedTodoPaneModel(t, []tool.Todo{{Text: "Scaffold the parser"}}) + + // Focused: elevated background (content lines included) + brighter border. + if !strings.Contains(focused, todoTestFocusBgSgrParams) { + t.Fatalf("focused pane does not paint the focused background (#1B1E28): %q", focused) + } + if !strings.Contains(focused, todoTestBorderSgrParams) { + t.Fatalf("focused pane does not use the brighter primary border (#E5A85C): %q", focused) + } + if strings.Contains(focused, todoTestAppBgSgrParams) { + t.Fatalf("focused pane still paints the app background (#0B0C0E): %q", focused) + } + + // Unfocused: unchanged baseline (app background, dark border). + if !strings.Contains(unfocused, todoTestAppBgSgrParams) { + t.Fatalf("unfocused pane does not paint the app background (#0B0C0E): %q", unfocused) + } + if strings.Contains(unfocused, todoTestFocusBgSgrParams) { + t.Fatalf("unfocused pane paints the focused background (#1B1E28): %q", unfocused) + } + if strings.Contains(unfocused, todoTestBorderSgrParams) { + t.Fatalf("unfocused pane uses the primary border (#E5A85C): %q", unfocused) + } +} + +// TestTodoPaneFocusedScreenVTEClean runs the focused pane through the real +// screen pipeline (View -> sanitizeVTE) and reuses validateNoVTELeaks to prove +// every cell — pane background included — carries an explicit background, so +// the focused color survives VTE reset re-assertion. +func TestTodoPaneFocusedScreenVTEClean(t *testing.T) { + focused, _, _, _ := newFocusedTodoPaneModel(t, []tool.Todo{{Text: "Scaffold the parser"}}) + + screen := focused.View().Content + if !strings.Contains(screen, "[focused · esc to unfocus]") { + t.Fatalf("focused pane missing from rendered screen: %q", ansi.Strip(screen)) + } + if !strings.Contains(screen, todoTestFocusBgSgrParams) { + t.Fatalf("rendered screen does not paint the focused todo pane background: %q", screen) + } + validateNoVTELeaks(t, "focused todo pane", screen) +} diff --git a/internal/tui/todo_routing_test.go b/internal/tui/todo_routing_test.go index 5f4ebb8..9f2fe78 100644 --- a/internal/tui/todo_routing_test.go +++ b/internal/tui/todo_routing_test.go @@ -79,3 +79,189 @@ func TestTodoCommandResetsInput(t *testing.T) { t.Fatalf("expected input to be reset to empty string after /todos, got %q", got) } } + +// newTodoKeyRoutingModel mirrors TestTodoScrollRouting's construction: a root +// orchestrator with a list_todos provider serving todoCount "Task" entries. +// Callers mirror the startup sequence: ShowTodoPane is set BEFORE SetSize so +// updateLayout reserves the side-pane width at >= 85 columns, then the +// viewport height is pinned so scroll math is deterministic. +func newTodoKeyRoutingModel(t *testing.T, todoCount int) Model { + t.Helper() + sess := session.New(nil, "", nil, "", false) + todos := make([]tool.Todo, todoCount) + for i := range todos { + todos[i].Text = "Task" + } + sess.Registry.Register(tool.ListTodosTool{Todos: &todos}) + root := orchestrator.NewBaseOrchestrator(common.MainAgentID, sess, nil, 0) + m := NewModel(root, nil, nil) + m.ShowTodoPane = true + m.SetSize(100, 30) + m.Viewport.SetHeight(10) + m.Input.SetValue("") + return m +} + +// TestTodoPaneUnfocusedNavKeysLandInInput pins the input-safety rule: an +// unfocused todo pane consumes NOTHING. j/k/g are only nav keys while the +// pane is focused; unfocused they are ordinary typing and must accumulate in +// the chat input without touching the todo scroll offset. +func TestTodoPaneUnfocusedNavKeysLandInInput(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = false + m.TodoScrollOffset = 10 + + for _, key := range []string{"j", "k", "g"} { + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: rune(key[0]), Text: key})) + m = updated.(Model) + } + + if m.TodoPaneFocused { + t.Fatal("unfocused pane must stay unfocused while typing") + } + if got := m.Input.Value(); got != "jkg" { + t.Fatalf("input value = %q, want %q (keys must reach the chat input)", got, "jkg") + } + if m.TodoScrollOffset != 10 { + t.Fatalf("todo offset = %d, want unchanged 10", m.TodoScrollOffset) + } +} + +// TestTodoPaneUnfocusedKPassesThroughToInput documents the original bug as a +// permanent regression pin: the focused handler used to be the only thing +// between "k" and the input, and once focus styling existed the trap became +// visible. With the pane shown but not focused, "k" must reach the chat +// input and never change TodoScrollOffset. +func TestTodoPaneUnfocusedKPassesThroughToInput(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = false + m.TodoScrollOffset = 10 + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'k', Text: "k"})) + m = updated.(Model) + + if m.TodoScrollOffset != 10 { + t.Fatalf("todo offset = %d, want unchanged 10 (unfocused pane consumes nothing)", m.TodoScrollOffset) + } + if got := m.Input.Value(); got != "k" { + t.Fatalf("input value = %q, want %q", got, "k") + } +} + +// TestTodoPaneFocusedPrintableKeyReleasesFocusAndTypes covers the escape +// hatch for the j/k/g typing trap: while focused, the first printable +// non-nav key releases focus AND falls through so the same keypress lands in +// the chat input (no key is ever silently swallowed). +func TestTodoPaneFocusedPrintableKeyReleasesFocusAndTypes(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = true + m.TodoScrollOffset = 10 + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"})) + m = updated.(Model) + + if m.TodoPaneFocused { + t.Fatal("printable key must release todo pane focus") + } + if got := m.Input.Value(); got != "x" { + t.Fatalf("input value = %q, want %q (released key must reach the chat input)", got, "x") + } + if m.TodoScrollOffset != 10 { + t.Fatalf("todo offset = %d, want unchanged 10 (x is not a nav key)", m.TodoScrollOffset) + } +} + +// TestTodoPaneFocusedNavKeyScrollsAndStaysFocused pins the other half of the +// contract: while focused, j remains a nav key — it scrolls the pane, keeps +// focus, and types nothing. +func TestTodoPaneFocusedNavKeyScrollsAndStaysFocused(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = true + m.TodoScrollOffset = 10 + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Text: "j"})) + m = updated.(Model) + + if !m.TodoPaneFocused { + t.Fatal("nav key must keep todo pane focus") + } + if m.TodoScrollOffset != 11 { + t.Fatalf("todo offset = %d, want 11", m.TodoScrollOffset) + } + if got := m.Input.Value(); got != "" { + t.Fatalf("input value = %q, want empty (nav key must not type)", got) + } +} + +// TestTodoPaneClickOutsideReleasesFocus: a left click in the transcript +// region (left of the 44-column pane on a 100-column terminal) unfocuses the +// pane — the click itself keeps its normal downstream work. +func TestTodoPaneClickOutsideReleasesFocus(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = true + + updated, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: 1, Y: 1, Button: tea.MouseLeft})) + m = updated.(Model) + + if m.TodoPaneFocused { + t.Fatal("left click outside the todo pane must release focus") + } +} + +// TestTodoPaneClickInsideGainsFocus pins the existing focus-on-click +// behavior: a left click inside the pane region focuses it. +func TestTodoPaneClickInsideGainsFocus(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = false + + updated, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: 99, Y: 1, Button: tea.MouseLeft})) + m = updated.(Model) + + if !m.TodoPaneFocused { + t.Fatal("left click inside the todo pane must focus it") + } +} + +// TestKeyReleaseNeverScrollsOrUnfocusesTodoPane pins the release-event fix: +// tea.KeyReleaseMsg implements tea.KeyMsg, so on terminals that report +// release events (kitty keyboard protocol) a release of "j" used to match the +// pane's nav switch a second time — double-scrolling — and a release of +// other keys could still reach downstream handlers. Releases must be inert. +func TestKeyReleaseNeverScrollsOrUnfocusesTodoPane(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = true + m.TodoScrollOffset = 10 + + updated, _ := m.Update(tea.KeyReleaseMsg(tea.Key{Code: rune('j'), Text: "j"})) + next := updated.(Model) + + if !next.TodoPaneFocused { + t.Fatal("a key release must never unfocus the pane") + } + if next.TodoScrollOffset != 10 { + t.Fatalf("todo offset = %d after a key release, want unchanged 10 (no double scroll)", next.TodoScrollOffset) + } + if got := next.Input.Value(); got != "" { + t.Fatalf("input = %q after a key release, want nothing typed", got) + } +} + +// TestTodoPaneFocusReleasesOnMultiRunePrintableKey covers dead-key / +// combining-sequence input: some keyboards deliver "e"+U+0301 as ONE event +// whose Key.Text holds multiple runes. That is typing — the pane must release +// focus so the text lands in the chat input, exactly like a single-rune key. +func TestTodoPaneFocusReleasesOnMultiRunePrintableKey(t *testing.T) { + m := newTodoKeyRoutingModel(t, 30) + m.TodoPaneFocused = true + m.TodoScrollOffset = 10 + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'e', Text: "é"})) + next := updated.(Model) + + if next.TodoPaneFocused { + t.Fatal("a multi-rune printable key (composed é) must release the pane focus") + } + if next.TodoScrollOffset != 10 { + t.Fatalf("todo offset = %d, want unchanged 10", next.TodoScrollOffset) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 892d195..8397757 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -17,6 +17,7 @@ import ( "sort" "strings" "time" + "unicode" "unicode/utf8" "charm.land/bubbles/v2/spinner" @@ -253,6 +254,26 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { m.TodoScrollOffset = maxOffset return m, nil } + // Typing must never be silently trapped while the pane holds + // focus: the first printable, non-nav key releases focus and — + // with no early return — falls through below so the same key + // reaches the chat input. j/k/g/G stay nav keys while focused + // (the switch above consumed them); every other printable + // character both unfocuses and types. bubbletea v2 populates + // Key.Text only for printable characters (the exact bytes the + // textarea inserts), so esc/ctrl/alt/pgup-style keystrokes never + // match here and keep the pane focused. Combining/dead-key + // sequences deliver multi-rune Text ("e"+U+0301 in one event) — + // any printable rune in it is typing, so release on the first. + if press, ok := keyMsg.(tea.KeyPressMsg); ok && + press.Mod&(tea.ModCtrl|tea.ModAlt|tea.ModMeta|tea.ModHyper|tea.ModSuper) == 0 { + for _, r := range press.Text { + if !unicode.IsControl(r) { + m.TodoPaneFocused = false + break + } + } + } } if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok { @@ -270,6 +291,14 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { if clickMsg, ok := msg.(tea.MouseClickMsg); ok { mouse := clickMsg.Mouse() + // A left click outside the pane releases its focus but must not + // eat the click: no early return, so the click keeps its normal + // downstream work (transcript selection/copy, input placement). + if mouse.Button == tea.MouseLeft && + m.TodoPaneFocused && + mouse.X < m.Width-todoPaneWidth { + m.TodoPaneFocused = false + } if mouse.Button == tea.MouseLeft && mouse.X >= m.Width-todoPaneWidth && mouse.Y >= 0 && mouse.Y < m.Viewport.Height() { diff --git a/internal/tui/view.go b/internal/tui/view.go index 8f7bf36..a7b9779 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -360,7 +360,7 @@ func (m *Model) renderMinimalEqualizerAt(now time.Time) string { // Gentle incommensurate harmonic (golden ratio 1.618) creates organic, non-repeating crests // Low amplitude ensures it never causes erratic snap or jitter - w2 := 0.35 * math.Sin(t*0.93 + float64(i)*0.55 + 1.2) + w2 := 0.35 * math.Sin(t*0.93+float64(i)*0.55+1.2) // Breathing envelope gives gentle natural cadence swell := 0.88 + 0.20*math.Sin(t*0.38+float64(i)*0.25) @@ -1660,7 +1660,7 @@ func (m Model) todoPaneView(height int) string { var lines []string focusMarker := "" if m.TodoPaneFocused { - focusMarker = " • focused" + focusMarker = " [focused · esc to unfocus]" } title := lipgloss.NewStyle(). Bold(true). @@ -1703,13 +1703,19 @@ func (m Model) todoPaneView(height int) string { } content := strings.Join(lines, "\n") + // The pane must be visibly different while focused: elevated background + // (applied to the whole box, content lines included) plus a brighter border. + bg, border := appBgColor, lipgloss.Color("#232329") + if m.TodoPaneFocused { + bg, border = todoFocusedBg, primaryColor + } boxStyle := lipgloss.NewStyle(). Width(innerWidth). Height(innerHeight). MaxHeight(innerHeight). Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(lipgloss.Color("#232329")). - Background(appBgColor) + BorderForeground(border). + Background(bg) return boxStyle.Render(content) }