From e5123078fe20d1b3de9b7a7665094ed7d9d6a875 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 30 Sep 2026 00:02:57 +0200 Subject: [PATCH] feat: add settings command to lean TUI Signed-off-by: Djordje Lukic --- docs/configuration/user-settings/index.md | 2 +- pkg/leantui/commands.go | 1 + pkg/leantui/events.go | 28 +++ pkg/leantui/leantui.go | 10 + pkg/leantui/settings.go | 221 ++++++++++++++++++++++ pkg/leantui/settings_test.go | 212 +++++++++++++++++++++ pkg/leantui/ui/screen.go | 10 +- pkg/leantui/ui/settings.go | 26 +++ pkg/leantui/update.go | 18 +- 9 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 pkg/leantui/settings.go create mode 100644 pkg/leantui/settings_test.go create mode 100644 pkg/leantui/ui/settings.go diff --git a/docs/configuration/user-settings/index.md b/docs/configuration/user-settings/index.md index b49543ee2e..d495eaed34 100644 --- a/docs/configuration/user-settings/index.md +++ b/docs/configuration/user-settings/index.md @@ -27,7 +27,7 @@ settings: sound: true ``` -You rarely need to hand-edit this file. Most fields are managed from the TUI's `/settings` dialog (**Appearance**, **Behavior**, **Notifications** tabs) — press Enter there to apply and persist a change. A few fields (`permissions`, `hooks`, `keybindings`) have no dialog UI and are only set by editing the file directly. +You rarely need to hand-edit this file. Most fields are managed from the TUI's `/settings` dialog (**Appearance**, **Behavior**, **Notifications** tabs) — press Enter there to apply and persist a change. The lean TUI has a smaller inline `/settings` panel with only relevant behavior settings: steering or queueing messages, default tool auto-approval, automatic snapshots, cache-stable prompts, cache-miss warnings, lean UI by default, completion sounds and their duration threshold, image rendering, the startup banner, and split diffs. It has no theme, sidebar, or tab settings. Use the arrow keys or Space to change a value, Enter to save, or Esc to cancel. Settings marked **next launch** do not change the current session. A few fields (`permissions`, `hooks`, `keybindings`) have no dialog UI and are only set by editing the file directly. > [!NOTE] > This page documents `settings:`. The user config file also has top-level sections outside `settings:` — `aliases:`, `providers:`, `board:`, `credential_helper:`, `sandbox_allowlist:` — which are not covered here. diff --git a/pkg/leantui/commands.go b/pkg/leantui/commands.go index a9fb41d7ee..7174d46b2c 100644 --- a/pkg/leantui/commands.go +++ b/pkg/leantui/commands.go @@ -12,6 +12,7 @@ func builtinCommands() []ui.Command { {Name: "effort", Desc: "Set the model's reasoning effort (usage: /effort )", Kind: ui.CmdBuiltin}, {Name: "copy", Desc: "Copy the last assistant response", Kind: ui.CmdBuiltin}, {Name: "clear", Desc: "Clear the screen", Kind: ui.CmdBuiltin}, + {Name: "settings", Desc: "Change lean TUI settings", Kind: ui.CmdBuiltin}, {Name: "help", Desc: "Show keyboard shortcuts and commands", Kind: ui.CmdBuiltin}, {Name: "exit", Desc: "Exit", Kind: ui.CmdBuiltin}, {Name: "quit", Desc: "Exit", Kind: ui.CmdBuiltin}, diff --git a/pkg/leantui/events.go b/pkg/leantui/events.go index 3455ef1ffb..0f09509c54 100644 --- a/pkg/leantui/events.go +++ b/pkg/leantui/events.go @@ -6,10 +6,12 @@ import ( "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/sound" "github.com/docker/docker-agent/pkg/tools" builtinshell "github.com/docker/docker-agent/pkg/tools/builtin/shell" msgtypes "github.com/docker/docker-agent/pkg/tui/messages" tuitypes "github.com/docker/docker-agent/pkg/tui/types" + "github.com/docker/docker-agent/pkg/userconfig" ) // handleEvent applies a single runtime event emitted by the App to the model, @@ -26,12 +28,21 @@ func (m *model) handleEvent(ctx context.Context, ev any) { m.submitFollowUp(ctx, e.Content) } case *runtime.StreamStartedEvent: + if m.streamDepth == 0 { + m.streamStartTime = time.Now() + } + m.streamDepth++ m.busy = true m.trackStreamStarted(e.SessionID) case *runtime.UserMessageEvent: m.handleUserMessageEvent(e) case *runtime.StreamStoppedEvent: m.trackStreamStopped() + m.streamDepth = max(0, m.streamDepth-1) + if m.streamDepth > 0 { + return + } + m.notifyStreamStopped(ctx, e.Reason) m.handleStreamStopped(ctx) case *runtime.AgentChoiceReasoningEvent: m.screen.Transcript.AppendReasoning(e.Content) @@ -89,6 +100,9 @@ func (m *model) handleEvent(ctx context.Context, ev any) { case *runtime.SessionCompactionEvent: m.handleSessionCompaction(ctx, e) case *runtime.ErrorEvent: + if m.playSound != nil && userconfig.Get().GetSound() { + m.playSound(ctx, sound.Failure) + } m.screen.Transcript.FlushPending() m.addNotice("✗ ", e.Error, ui.StError()) case *runtime.WarningEvent: @@ -217,3 +231,17 @@ func (m *model) applyTeamInfo(ctx context.Context, e *runtime.TeamInfoEvent) { } m.refreshCommands(ctx) } + +func (m *model) notifyStreamStopped(ctx context.Context, reason string) { + if m.playSound == nil || m.streamStartTime.IsZero() { + return + } + defer func() { m.streamStartTime = time.Time{} }() + switch reason { + case "", "normal", "continue", "steered": + settings := userconfig.Get() + if settings.GetSound() && time.Since(m.streamStartTime) >= time.Duration(settings.GetSoundThreshold())*time.Second { + m.playSound(ctx, sound.Success) + } + } +} diff --git a/pkg/leantui/leantui.go b/pkg/leantui/leantui.go index 2f1af10dce..2f17ad0033 100644 --- a/pkg/leantui/leantui.go +++ b/pkg/leantui/leantui.go @@ -14,9 +14,12 @@ import ( "github.com/docker/docker-agent/pkg/gitbranch" "github.com/docker/docker-agent/pkg/history" "github.com/docker/docker-agent/pkg/leantui/ui" + "github.com/docker/docker-agent/pkg/sound" "github.com/docker/docker-agent/pkg/tui/components/completion" "github.com/docker/docker-agent/pkg/tui/components/editor/completions" + "github.com/docker/docker-agent/pkg/tui/messages" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/userconfig" ) // Config wires the lean TUI to a prepared App and the initial run parameters. @@ -202,6 +205,9 @@ type model struct { sessionState *service.SessionState usage *ui.UsageTracker + streamDepth int + streamStartTime time.Time + playSound func(context.Context, sound.Event) busy bool spinnerFrame int runCancel context.CancelFunc @@ -215,6 +221,8 @@ type model struct { banner []string disabledCommands map[string]bool renderImages bool + sendMode messages.SendMode + settings *leanSettings // hideBanner drops the ASCII-art welcome banner; the zero value keeps it. hideBanner bool } @@ -241,6 +249,7 @@ func newModel(term *ui.Terminal, cfg Config) *model { return &model{ app: cfg.App, + playSound: sound.Play, term: term, r: ui.NewRenderer(term.Writer(), w, h), width: w, @@ -253,6 +262,7 @@ func newModel(term *ui.Terminal, cfg Config) *model { banner: cfg.Banner, disabledCommands: disabled, renderImages: renderImages, + sendMode: messages.ParseSendMode(userconfig.Get().GetBusySendMode()), hideBanner: cfg.ShowBanner != nil && !*cfg.ShowBanner, } } diff --git a/pkg/leantui/settings.go b/pkg/leantui/settings.go new file mode 100644 index 0000000000..f1f5aaaf02 --- /dev/null +++ b/pkg/leantui/settings.go @@ -0,0 +1,221 @@ +package leantui + +import ( + "fmt" + "os" + + "github.com/docker/docker-agent/pkg/leantui/ui" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/userconfig" +) + +const ( + settingSendMode = iota + settingYOLO + settingSnapshot + settingCacheStablePrompts + settingWarnOnCacheMiss + settingLean + settingSound + settingSoundThreshold + settingRenderImages + settingShowBanner + settingSplitDiff + settingCount +) + +type leanSettings struct { + values [settingCount]bool + original [settingCount]bool + soundThreshold int + originalThreshold int +} + +func (m *model) openSettings() { + s := userconfig.Get() + values := [settingCount]bool{ + settingSendMode: m.sendMode == messages.SendModeQueue, + settingYOLO: s.YOLO, + settingSnapshot: s.SnapshotsEnabled(), + settingCacheStablePrompts: s.CacheStablePromptsEnabled(), + settingWarnOnCacheMiss: s.CacheMissWarningsEnabled(), + settingLean: s.Lean, + settingSound: s.GetSound(), + settingRenderImages: m.renderImages, + settingShowBanner: !m.hideBanner, + settingSplitDiff: m.sessionState.SplitDiffView(), + } + m.settings = &leanSettings{values: values, original: values, soundThreshold: s.GetSoundThreshold(), originalThreshold: s.GetSoundThreshold()} + m.screen.Settings = &ui.SettingsModel{} + m.screen.Autocomplete.Dismiss() + m.refreshSettingsRows() +} + +func (m *model) refreshSettingsRows() { + labels := [settingCount]string{ + "While agent is working", + "Auto-approve tools by default (next launch)", + "Automatic snapshots (next launch)", + "Cache-stable dynamic prompts", + "Warn on cache miss", + "Lean UI by default (next launch)", + "Completion sound", + "Sound threshold", + "Render images", + "Show startup banner (next launch)", + "Split diff view", + } + rows := make([]ui.SettingRow, settingCount) + for i, label := range labels { + value := "Off" + if m.settings.values[i] { + value = "On" + } + if i == settingSendMode { + value = "Steer" + if m.settings.values[i] { + value = "Queue" + } + } + if i == settingSoundThreshold { + value = fmt.Sprintf("%d seconds", m.settings.soundThreshold) + if !m.settings.values[settingSound] { + value += " (sound off)" + } + } + rows[i] = ui.SettingRow{Label: label, Value: value} + } + m.screen.Settings.Rows = rows +} + +func (m *model) handleSettingsKey(k ui.Key) { + panel := m.screen.Settings + switch k.Typ { + case ui.KeyEsc, ui.KeyCtrlC: + m.closeSettings() + return + case ui.KeyUp: + panel.Selected = max(0, panel.Selected-1) + panel.ConfirmYOLO = false + case ui.KeyDown: + panel.Selected = min(settingCount-1, panel.Selected+1) + panel.ConfirmYOLO = false + case ui.KeyHome: + panel.Selected = 0 + panel.ConfirmYOLO = false + case ui.KeyEnd: + panel.Selected = settingCount - 1 + panel.ConfirmYOLO = false + case ui.KeyLeft: + m.changeSetting(-1) + case ui.KeyRight: + m.changeSetting(1) + case ui.KeyRune: + if string(k.Runes) == " " { + m.changeSetting(1) + } + case ui.KeyEnter: + if m.settings.values != m.settings.original || m.settings.soundThreshold != m.settings.originalThreshold { + if err := saveLeanSettings(m.settings); err != nil { + m.addNotice("✗ ", "Failed to save settings: "+err.Error(), ui.StError()) + return + } + m.sendMode = messages.SendModeSteer + if m.settings.values[settingSendMode] { + m.sendMode = messages.SendModeQueue + } + if m.settings.values[settingRenderImages] != m.settings.original[settingRenderImages] { + m.renderImages = m.settings.values[settingRenderImages] && tuiimage.SupportsKittyGraphics(os.Stdin, os.Stdout) + } + m.hideBanner = !m.settings.values[settingShowBanner] + m.sessionState.SetSplitDiffView(m.settings.values[settingSplitDiff]) + m.addNotice("", "Settings updated.", ui.StMuted()) + } + m.closeSettings() + return + } + m.refreshSettingsRows() +} + +func (m *model) changeSetting(delta int) { + panel := m.screen.Settings + row := panel.Selected + if row == settingSoundThreshold { + if m.settings.values[settingSound] { + m.settings.soundThreshold = max(1, min(300, m.settings.soundThreshold+delta)) + } + return + } + if row == settingYOLO && !m.settings.values[row] && !panel.ConfirmYOLO { + panel.ConfirmYOLO = true + return + } + m.settings.values[row] = !m.settings.values[row] + panel.ConfirmYOLO = false +} + +func (m *model) closeSettings() { + m.settings = nil + m.screen.Settings = nil +} + +func saveLeanSettings(p *leanSettings) error { + return userconfig.Update(func(cfg *userconfig.Config) error { + if cfg.Settings == nil { + cfg.Settings = &userconfig.Settings{} + } + s := cfg.Settings + for i, value := range p.values { + if value == p.original[i] { + continue + } + switch i { + case settingSendMode: + s.BusySendMode = "" + if value { + s.BusySendMode = string(messages.SendModeQueue) + } + case settingYOLO: + s.YOLO = value + case settingSnapshot: + s.Snapshot = enabledPreference(value) + case settingCacheStablePrompts: + s.CacheStablePrompts = enabledPreference(value) + case settingWarnOnCacheMiss: + s.WarnOnCacheMiss = enabledPreference(value) + case settingLean: + s.Lean = value + case settingSound: + s.Sound = value + case settingRenderImages: + s.RenderImages = defaultOnPreference(value) + case settingShowBanner: + s.ShowBanner = defaultOnPreference(value) + case settingSplitDiff: + s.SplitDiffView = defaultOnPreference(value) + } + } + if p.soundThreshold != p.originalThreshold { + s.SoundThreshold = p.soundThreshold + if s.SoundThreshold == userconfig.DefaultSoundThreshold { + s.SoundThreshold = 0 + } + } + return nil + }) +} + +func enabledPreference(enabled bool) *bool { + if !enabled { + return nil + } + return new(true) +} + +func defaultOnPreference(enabled bool) *bool { + if enabled { + return nil + } + return new(false) +} diff --git a/pkg/leantui/settings_test.go b/pkg/leantui/settings_test.go new file mode 100644 index 0000000000..ef0ae881f8 --- /dev/null +++ b/pkg/leantui/settings_test.go @@ -0,0 +1,212 @@ +package leantui + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/leantui/ui" + "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/sound" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/userconfig" +) + +func setupLeanSettingsTest(t *testing.T) { + t.Helper() + paths.SetConfigDir(t.TempDir()) + t.Cleanup(func() { paths.SetConfigDir("") }) +} + +func TestSettingsCommand(t *testing.T) { + setupLeanSettingsTest(t) + m := bareModel(80) + require.True(t, m.handleSlash(t.Context(), "/settings", busySubmitSteer)) + require.NotNil(t, m.screen.Settings) + lines, _, _ := m.buildLines() + view := strings.Join(lines, "\n") + assert.Contains(t, view, "Settings") + assert.Contains(t, view, "Cache-stable dynamic prompts") + for _, excluded := range []string{"Appearance", "Theme", "Sidebar", "Restore tabs", "Interrupt confirmation"} { + assert.NotContains(t, view, excluded) + } + m.handleKey(t.Context(), ui.Key{Typ: ui.KeyRight}) + m.handleKey(t.Context(), ui.Key{Typ: ui.KeyEsc}) + assert.Nil(t, m.screen.Settings) + assert.Empty(t, m.sendMode) + _, err := os.Stat(userconfig.Path()) + assert.True(t, os.IsNotExist(err)) +} + +func TestSettingsSavePreservesOtherPreferences(t *testing.T) { + setupLeanSettingsTest(t) + cfg := &userconfig.Config{Settings: &userconfig.Settings{ + Theme: "dracula", Sound: true, RestoreTabs: new(true), + Layout: &userconfig.LayoutSettings{SidebarPosition: "left"}, + }} + require.NoError(t, cfg.Save()) + m := bareModel(80) + m.openSettings() + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + m.screen.Settings.Selected = settingWarnOnCacheMiss + m.handleSettingsKey(ui.Key{Typ: ui.KeyRune, Runes: []rune{' '}}) + m.handleSettingsKey(ui.Key{Typ: ui.KeyEnter}) + assert.Nil(t, m.screen.Settings) + assert.Equal(t, messages.SendModeQueue, m.sendMode) + s := userconfig.Get() + assert.Equal(t, "queue", s.GetBusySendMode()) + assert.True(t, s.CacheMissWarningsEnabled()) + assert.Equal(t, cfg.Settings.Theme, s.Theme) + assert.Equal(t, cfg.Settings.Sound, s.Sound) + assert.Equal(t, cfg.Settings.RestoreTabs, s.RestoreTabs) + assert.Equal(t, cfg.Settings.Layout, s.Layout) +} + +func TestSettingsYOLORequiresConfirmation(t *testing.T) { + setupLeanSettingsTest(t) + m := bareModel(80) + m.openSettings() + m.screen.Settings.Selected = settingYOLO + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + assert.True(t, m.screen.Settings.ConfirmYOLO) + assert.NotContains(t, strings.Join(m.screen.Settings.Render(120), "\n"), "Auto-approve can run tools without confirmation") + assert.False(t, m.settings.values[settingYOLO]) + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + assert.False(t, m.screen.Settings.ConfirmYOLO) + assert.True(t, m.settings.values[settingYOLO]) + m.handleSettingsKey(ui.Key{Typ: ui.KeyEnter}) + assert.True(t, userconfig.Get().YOLO) +} + +func TestSettingsSaveFailureKeepsPanelOpen(t *testing.T) { + setupLeanSettingsTest(t) + m := bareModel(80) + m.openSettings() + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + require.NoError(t, os.WriteFile(userconfig.Path(), []byte("settings: ["), 0o600)) + m.handleSettingsKey(ui.Key{Typ: ui.KeyEnter}) + assert.NotNil(t, m.screen.Settings) + assert.Empty(t, m.sendMode) + lines, _, _ := m.buildLines() + assert.Contains(t, strings.Join(lines, "\n"), "Failed to save settings") +} + +func TestSettingsBusySendModeQueuesMessages(t *testing.T) { + rt := &cycleThinkingRuntime{} + m := bareModel(80) + m.app = app.New(t.Context(), rt, session.New()) + m.busy = true + m.sendMode = messages.SendModeQueue + m.submitEditor(t.Context(), "next task") + assert.Empty(t, rt.steered) + require.Len(t, m.queue, 1) + assert.Equal(t, "next task", m.queue[0].Content) +} + +func TestSettingsOnlySavesChangedValues(t *testing.T) { + setupLeanSettingsTest(t) + m := bareModel(80) + m.openSettings() + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + require.NoError(t, userconfig.Update(func(cfg *userconfig.Config) error { + cfg.Settings = &userconfig.Settings{Lean: true} + return nil + })) + m.handleSettingsKey(ui.Key{Typ: ui.KeyEnter}) + assert.True(t, userconfig.Get().Lean) +} + +func TestSettingsSoundThreshold(t *testing.T) { + setupLeanSettingsTest(t) + m := bareModel(80) + m.openSettings() + m.screen.Settings.Selected = settingSoundThreshold + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + assert.Equal(t, userconfig.DefaultSoundThreshold, m.settings.soundThreshold) + m.screen.Settings.Selected = settingSound + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + m.screen.Settings.Selected = settingSoundThreshold + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + assert.Equal(t, userconfig.DefaultSoundThreshold+1, m.settings.soundThreshold) + m.settings.soundThreshold = 300 + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + assert.Equal(t, 300, m.settings.soundThreshold) + m.settings.soundThreshold = 1 + m.handleSettingsKey(ui.Key{Typ: ui.KeyLeft}) + assert.Equal(t, 1, m.settings.soundThreshold) + m.handleSettingsKey(ui.Key{Typ: ui.KeyEnter}) + assert.True(t, userconfig.Get().GetSound()) + assert.Equal(t, 1, userconfig.Get().GetSoundThreshold()) +} + +func TestSettingsRenderingPreferences(t *testing.T) { + setupLeanSettingsTest(t) + m := bareModel(80) + m.renderImages = true + m.openSettings() + for _, row := range []int{settingRenderImages, settingShowBanner, settingSplitDiff} { + m.screen.Settings.Selected = row + m.handleSettingsKey(ui.Key{Typ: ui.KeyRight}) + } + m.handleSettingsKey(ui.Key{Typ: ui.KeyEnter}) + assert.False(t, m.renderImages) + assert.True(t, m.hideBanner) + assert.False(t, m.sessionState.SplitDiffView()) + s := userconfig.Get() + assert.False(t, s.GetRenderImages()) + assert.False(t, s.GetShowBanner()) + assert.False(t, s.GetSplitDiffView()) +} + +func TestCompletionSound(t *testing.T) { + setupLeanSettingsTest(t) + require.NoError(t, (&userconfig.Config{Settings: &userconfig.Settings{Sound: true, SoundThreshold: 10}}).Save()) + for _, tc := range []struct { + name string + duration time.Duration + reason string + want bool + }{ + {"completed", 11 * time.Second, "normal", true}, + {"too short", time.Second, "normal", false}, + {"cancelled", 11 * time.Second, "cancelled", false}, + {"failed", 11 * time.Second, "error", false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := bareModel(80) + var played []sound.Event + m.playSound = func(_ context.Context, event sound.Event) { played = append(played, event) } + m.handleEvent(t.Context(), runtime.StreamStarted("root", "root")) + m.streamStartTime = time.Now().Add(-tc.duration) + m.handleEvent(t.Context(), runtime.StreamStarted("child", "child")) + m.handleEvent(t.Context(), runtime.StreamStopped("child", "child", "normal")) + assert.Empty(t, played) + assert.True(t, m.busy) + m.handleEvent(t.Context(), runtime.StreamStopped("root", "root", tc.reason)) + assert.Equal(t, tc.want, len(played) == 1) + m.handleEvent(t.Context(), runtime.StreamStopped("root", "root", tc.reason)) + assert.LessOrEqual(t, len(played), 1) + }) + } +} + +func TestFailureSound(t *testing.T) { + setupLeanSettingsTest(t) + require.NoError(t, (&userconfig.Config{Settings: &userconfig.Settings{Sound: true}}).Save()) + m := bareModel(80) + var played []sound.Event + m.playSound = func(_ context.Context, event sound.Event) { played = append(played, event) } + m.handleEvent(t.Context(), &runtime.ErrorEvent{Error: "failed"}) + assert.Equal(t, []sound.Event{sound.Failure}, played) + require.NoError(t, (&userconfig.Config{}).Save()) + m.handleEvent(t.Context(), &runtime.ErrorEvent{Error: "failed"}) + assert.Len(t, played, 1) +} diff --git a/pkg/leantui/ui/screen.go b/pkg/leantui/ui/screen.go index f6d4766441..a73f2a0783 100644 --- a/pkg/leantui/ui/screen.go +++ b/pkg/leantui/ui/screen.go @@ -12,6 +12,7 @@ type Screen struct { Autocomplete *Autocomplete Status StatusModel Confirm *ConfirmModel + Settings *SettingsModel } func NewScreen(workingDir, branch, editorPlaceholder string, historyStore ...*history.History) *Screen { @@ -30,14 +31,19 @@ func (s *Screen) Frame(width, _, spinnerFrame int, busy bool, sessionState servi lines = append(lines, s.Autocomplete.Render(width)...) inputStart := len(lines) - if s.Confirm != nil { + switch { + case s.Confirm != nil: confirmLines := s.Confirm.Render(width) lines = append(lines, confirmLines...) cursorLine = inputStart + max(len(confirmLines)-1, 0) if len(confirmLines) > 0 { cursorCol = min(DisplayWidth(confirmLines[len(confirmLines)-1]), max(width-1, 0)) } - } else { + case s.Settings != nil: + settingsLines := s.Settings.Render(width) + lines = append(lines, settingsLines...) + cursorLine = inputStart + len(settingsLines) - 1 + default: editorLines, row, col := s.Editor.Layout(width) lines = append(lines, editorLines...) cursorLine = inputStart + row diff --git a/pkg/leantui/ui/settings.go b/pkg/leantui/ui/settings.go new file mode 100644 index 0000000000..25232509ff --- /dev/null +++ b/pkg/leantui/ui/settings.go @@ -0,0 +1,26 @@ +package ui + +// SettingsModel is the inline settings panel shown in place of the editor. +type SettingsModel struct { + Rows []SettingRow + Selected int + ConfirmYOLO bool +} + +type SettingRow struct { + Label string + Value string +} + +func (s *SettingsModel) Render(width int) []string { + lines := []string{Truncate(StBold().Render("Settings"), width)} + for i, row := range s.Rows { + prefix, style := " ", StMuted() + if i == s.Selected { + prefix, style = "› ", StPrimary() + } + lines = append(lines, Truncate(style.Render(prefix+row.Label+": "+row.Value), width)) + } + + return append(lines, Truncate(StMuted().Render("↑/↓ navigate · ←/→/space change · Enter save · Esc cancel"), width)) +} diff --git a/pkg/leantui/update.go b/pkg/leantui/update.go index e7f0a5c207..721f96b0d4 100644 --- a/pkg/leantui/update.go +++ b/pkg/leantui/update.go @@ -8,6 +8,7 @@ import ( "log/slog" "path/filepath" "strings" + "time" "uuid" "charm.land/lipgloss/v2" @@ -36,6 +37,11 @@ func (m *model) handleKey(ctx context.Context, k ui.Key) { return } + if m.screen.Settings != nil { + m.handleSettingsKey(k) + return + } + switch k.Typ { case ui.KeyCtrlC: m.handleInterrupt() @@ -262,7 +268,11 @@ type submitOptions struct { } func (m *model) submitEditor(ctx context.Context, text string) { - m.submitEditorMode(ctx, text, busySubmitSteer) + mode := busySubmitSteer + if m.sendMode == messages.SendModeQueue { + mode = busySubmitQueue + } + m.submitEditorMode(ctx, text, mode) } func (m *model) submitEditorMode(ctx context.Context, text string, mode busySubmitMode) { @@ -338,6 +348,9 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod case "copy": m.copyLastResponse() return true + case "settings": + m.openSettings() + return true case "help": m.commitHelp() return true @@ -758,6 +771,8 @@ func (m *model) resetConversation() { m.pendingUsers = nil m.ignoredUsers = nil m.busy = false + m.streamDepth = 0 + m.streamStartTime = time.Time{} m.cancelMarkerPending = false m.screen.Confirm = nil m.usage.Reset() @@ -834,6 +849,7 @@ func (m *model) commitHelp() { ui.StMuted().Render(" /effort set the model's reasoning effort (e.g. /effort high)"), ui.StMuted().Render(" /copy copy the last assistant response"), ui.StMuted().Render(" /clear clear the screen"), + ui.StMuted().Render(" /settings change lean TUI settings"), ui.StMuted().Render(" /help show this help"), ui.StMuted().Render(" /exit quit"), "",