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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 4 additions & 0 deletions internal/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
181 changes: 181 additions & 0 deletions internal/tui/todo_pane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Loading
Loading