Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/configuration/user-settings/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Enter</kbd> 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 <kbd>Enter</kbd> 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 <kbd>Space</kbd> to change a value, <kbd>Enter</kbd> to save, or <kbd>Esc</kbd> 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.
Expand Down
1 change: 1 addition & 0 deletions pkg/leantui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func builtinCommands() []ui.Command {
{Name: "effort", Desc: "Set the model's reasoning effort (usage: /effort <level>)", 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},
Expand Down
28 changes: 28 additions & 0 deletions pkg/leantui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
}
}
10 changes: 10 additions & 0 deletions pkg/leantui/leantui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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,
}
}
Expand Down
221 changes: 221 additions & 0 deletions pkg/leantui/settings.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading