From 50e1ff29090764fc4d60889603b5f21a42f9dc07 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:12:28 +0200 Subject: [PATCH 1/5] feat(tui): todos panel open by default (+show-todo-pane config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveShowTodoPane treats an absent show-todo-pane entry (or nil config) as open, explicit false as closed, explicit true as open. main.go resolves it after config load and sets model.ShowTodoPane BEFORE the initial SetSize, so updateLayout reserves the 44-col side-pane width at >= 85 columns; below the threshold the same layout path silently starts with the pane closed (no startup toast — /todos still toasts when opened later on a narrow terminal). --- cmd/late/main.go | 13 +++++ internal/config/config.go | 20 ++++++++ internal/config/config_test.go | 30 ++++++++++++ internal/tui/todo_pane_test.go | 89 ++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/cmd/late/main.go b/cmd/late/main.go index bb16d27e..61072ec9 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 4b0463fc..4e280e5c 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 5d4f8c11..e8a7e14b 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/todo_pane_test.go b/internal/tui/todo_pane_test.go index ec4c3545..cc36ba78 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,86 @@ 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) + } +} From 01704d17989915a0834e97bf21e929a7444c6dc0 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:26:24 +0200 Subject: [PATCH 2/5] feat(tui): todo pane visibly highlights when focused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focused pane gets an elevated background (chipBgColor) and a primary border plus a '[focused · esc to unfocus]' title marker; unfocused pane is unchanged. --- internal/tui/styles.go | 4 ++ internal/tui/todo_pane_test.go | 92 ++++++++++++++++++++++++++++++++++ internal/tui/view.go | 14 ++++-- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index cbb4c008..d8b5efc1 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 cc36ba78..9ad227d2 100644 --- a/internal/tui/todo_pane_test.go +++ b/internal/tui/todo_pane_test.go @@ -200,3 +200,95 @@ func TestTodoPaneStartupWidthGuardIsSilent(t *testing.T) { 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/view.go b/internal/tui/view.go index 8f7bf367..a7b97791 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) } From 71dcb51e115b19d9fe1d8acdd642c356ac242961 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:38:36 +0200 Subject: [PATCH 3/5] fix(tui): todo pane never traps typing A printable key while the pane is focused releases focus and falls through to the input (j/k/g stay scroll keys while focused, now visible via the focus highlight); clicking outside the pane also releases focus; an unfocused pane provably consumes no keys. --- internal/tui/todo_routing_test.go | 142 ++++++++++++++++++++++++++++++ internal/tui/update.go | 24 +++++ 2 files changed, 166 insertions(+) diff --git a/internal/tui/todo_routing_test.go b/internal/tui/todo_routing_test.go index 5f4ebb8d..6bb01dd8 100644 --- a/internal/tui/todo_routing_test.go +++ b/internal/tui/todo_routing_test.go @@ -79,3 +79,145 @@ 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") + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 892d1957..885cd20b 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,21 @@ 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. + if press, ok := keyMsg.(tea.KeyPressMsg); ok && + press.Mod&(tea.ModCtrl|tea.ModAlt|tea.ModMeta|tea.ModHyper|tea.ModSuper) == 0 { + if runes := []rune(press.Text); len(runes) == 1 && !unicode.IsControl(runes[0]) { + m.TodoPaneFocused = false + } + } } if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok { @@ -270,6 +286,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() { From b14810092ef5a3b12bd9baba26000ddce903dc98 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:09:59 +0200 Subject: [PATCH 4/5] fix(tui): release todo-pane focus on any printable rune in multi-rune key text Dead-key / combining sequences deliver multi-rune Key.Text ("e"+U+0301 in one event); the old len(runes)==1 check missed them and kept trapping typing in the focused pane. Release on the first printable non-control rune instead. Review fix cherry-picked from 0a97d2f (todo-pane multi-rune portion and todo_routing_test.go additions only). --- internal/tui/todo_routing_test.go | 44 +++++++++++++++++++++++++++++++ internal/tui/update.go | 11 +++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/tui/todo_routing_test.go b/internal/tui/todo_routing_test.go index 6bb01dd8..9f2fe78a 100644 --- a/internal/tui/todo_routing_test.go +++ b/internal/tui/todo_routing_test.go @@ -221,3 +221,47 @@ func TestTodoPaneClickInsideGainsFocus(t *testing.T) { 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 885cd20b..af4e28ab 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -262,11 +262,16 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { // 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. + // 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 { - if runes := []rune(press.Text); len(runes) == 1 && !unicode.IsControl(runes[0]) { - m.TodoPaneFocused = false + for _, r := range []rune(press.Text) { + if !unicode.IsControl(r) { + m.TodoPaneFocused = false + break + } } } } From 53feb9de927a94076a1882fcb8dfb6071caee16d Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:29:29 +0200 Subject: [PATCH 5/5] fix(lint): range over key text string directly (staticcheck SA6003) golangci-lint v2.13.2 flags internal/tui/update.go:270: SA6003 should range over string, not []rune(string). Ranging over a string already yields runes; drop the redundant conversion. Behavior identical. Reproduced locally with golangci-lint v2.13.2; full go test ./... -race -count=1 green. --- internal/tui/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/update.go b/internal/tui/update.go index af4e28ab..8397757c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -267,7 +267,7 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { // 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 []rune(press.Text) { + for _, r := range press.Text { if !unicode.IsControl(r) { m.TodoPaneFocused = false break