From 463361da1667021e2b01a2e6cb7f3d7269f2b7a6 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 25 Mar 2026 23:54:29 +0100 Subject: [PATCH] Add --lean flag for simplified TUI mode Introduce a --lean flag to 'docker agent run' that renders a minimal TUI with just the message stream, a working indicator, and the editor input. No sidebar, tab bar, resize handle, status bar, or overlay dialogs are shown. The implementation reuses the existing TUI code path with conditional checks rather than maintaining a separate TUI. Assisted-By: docker-agent --- cmd/root/new.go | 6 +- cmd/root/run.go | 15 ++++- go.mod | 2 +- pkg/tui/page/chat/chat.go | 58 ++++++++++++---- pkg/tui/tui.go | 137 +++++++++++++++++++++++++++++++------- 5 files changed, 174 insertions(+), 44 deletions(-) diff --git a/cmd/root/new.go b/cmd/root/new.go index 1796d3cd2..03993250c 100644 --- a/cmd/root/new.go +++ b/cmd/root/new.go @@ -79,10 +79,10 @@ func (f *newFlags) runNewCommand(cmd *cobra.Command, args []string) error { sess := session.New(sessOpts...) - return runTUI(ctx, rt, sess, nil, nil, appOpts...) + return runTUI(ctx, rt, sess, nil, nil, nil, appOpts...) } -func runTUI(ctx context.Context, rt runtime.Runtime, sess *session.Session, spawner tui.SessionSpawner, cleanup func(), opts ...app.Opt) error { +func runTUI(ctx context.Context, rt runtime.Runtime, sess *session.Session, spawner tui.SessionSpawner, cleanup func(), tuiOpts []tui.Option, opts ...app.Opt) error { if gen := rt.TitleGenerator(); gen != nil { opts = append(opts, app.WithTitleGenerator(gen)) } @@ -105,7 +105,7 @@ func runTUI(ctx context.Context, rt runtime.Runtime, sess *session.Session, spaw cleanup = func() {} } wd, _ := os.Getwd() - model := tui.New(ctx, spawner, a, wd, cleanup) + model := tui.New(ctx, spawner, a, wd, cleanup, tuiOpts...) p := tea.NewProgram(model, tea.WithContext(ctx), tea.WithFilter(filter)) coalescer.SetSender(p.Send) diff --git a/cmd/root/run.go b/cmd/root/run.go index 557d22452..6af323098 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -60,6 +60,7 @@ type runExecFlags struct { // Run only hideToolResults bool + lean bool // globalPermissions holds the user-level global permission checker built // from user config settings. Nil when no global permissions are configured. @@ -117,6 +118,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { _ = cmd.PersistentFlags().MarkHidden("memprofile") cmd.PersistentFlags().BoolVar(&flags.forceTUI, "force-tui", false, "Force TUI mode even when not in a terminal") _ = cmd.PersistentFlags().MarkHidden("force-tui") + cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome") cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "", "Template image for the sandbox (passed to docker sandbox create -t)") cmd.MarkFlagsMutuallyExclusive("fake", "record") @@ -276,7 +278,7 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s } sessStore := rt.SessionStore() - return runTUI(ctx, rt, sess, f.createSessionSpawner(agentSource, sessStore), initialTeamCleanup, opts...) + return runTUI(ctx, rt, sess, f.createSessionSpawner(agentSource, sessStore), initialTeamCleanup, f.tuiOpts(), opts...) } func (f *runExecFlags) loadAgentFrom(ctx context.Context, agentSource config.Source) (*teamloader.LoadResult, error) { @@ -455,7 +457,16 @@ func (f *runExecFlags) launchTUI(ctx context.Context, out *cli.Printer, rt runti return err } - return runTUI(ctx, rt, sess, nil, nil, opts...) + return runTUI(ctx, rt, sess, nil, nil, f.tuiOpts(), opts...) +} + +// tuiOpts returns the TUI options derived from the current flags. +func (f *runExecFlags) tuiOpts() []tui.Option { + var opts []tui.Option + if f.lean { + opts = append(opts, tui.WithLeanMode()) + } + return opts } func (f *runExecFlags) buildAppOpts(args []string) ([]app.Opt, error) { diff --git a/go.mod b/go.mod index c33b40f40..259773094 100644 --- a/go.mod +++ b/go.mod @@ -185,7 +185,7 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index f067dbc18..f6c261599 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -139,7 +139,8 @@ type chatPage struct { sessionState *service.SessionState // State - working bool + working bool + leanMode bool msgCancel context.CancelFunc streamCancelled bool @@ -174,6 +175,16 @@ type chatPage struct { func (p *chatPage) computeSidebarLayout() sidebarLayout { innerWidth := p.width - appPaddingHorizontal + // Lean mode: no sidebar at all + if p.leanMode { + return sidebarLayout{ + mode: sidebarCollapsedNarrow, + innerWidth: innerWidth, + chatWidth: innerWidth, + chatHeight: max(1, p.height), + } + } + var mode sidebarLayoutMode switch { case p.width >= minWindowWidth && !p.sidebar.IsCollapsed(): @@ -300,7 +311,7 @@ func getEditorDisplayNameFromEnv(visual, editorEnv string) string { } // New creates a new chat page -func New(a *app.App, sessionState *service.SessionState) Page { +func New(a *app.App, sessionState *service.SessionState, opts ...PageOption) Page { p := &chatPage{ sidebar: sidebar.New(sessionState), messages: messages.New(sessionState), @@ -309,9 +320,23 @@ func New(a *app.App, sessionState *service.SessionState) Page { sessionState: sessionState, } + for _, opt := range opts { + opt(p) + } + return p } +// PageOption configures a chat page. +type PageOption func(*chatPage) + +// WithLeanMode creates a lean chat page with no sidebar. +func WithLeanMode() PageOption { + return func(p *chatPage) { + p.leanMode = true + } +} + // Init initializes the chat page func (p *chatPage) Init() tea.Cmd { var cmds []tea.Cmd @@ -518,19 +543,26 @@ func (p *chatPage) View() string { bodyContent = lipgloss.JoinHorizontal(lipgloss.Left, chatView, toggleCol, sidebarView) case sidebarCollapsed, sidebarCollapsedNarrow: - sidebarRendered := p.renderCollapsedSidebar(sl) - - chatView := styles.ChatStyle. - Height(sl.chatHeight). - Width(sl.innerWidth). - Render(messagesView) - - bodyContent = lipgloss.JoinVertical(lipgloss.Top, sidebarRendered, chatView) + if p.leanMode { + // Lean mode: no sidebar header, no fixed height + bodyContent = styles.ChatStyle. + Width(sl.innerWidth). + Render(messagesView) + } else { + sidebarRendered := p.renderCollapsedSidebar(sl) + chatView := styles.ChatStyle. + Height(sl.chatHeight). + Width(sl.innerWidth). + Render(messagesView) + bodyContent = lipgloss.JoinVertical(lipgloss.Top, sidebarRendered, chatView) + } } - return styles.AppStyle. - Height(p.height). - Render(bodyContent) + appStyle := styles.AppStyle + if !p.leanMode { + appStyle = appStyle.Height(p.height) + } + return appStyle.Render(bodyContent) } // renderSidebarHandle renders the sidebar toggle/resize handle. diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 153cb4ce2..a63520bc0 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -156,10 +156,24 @@ type appModel struct { ready bool err error + + // leanMode enables a simplified TUI with minimal chrome. + leanMode bool +} + +// Option configures the TUI. +type Option func(*appModel) + +// WithLeanMode enables a simplified TUI with minimal chrome: +// no sidebar, no tab bar, no overlays, no resize handle. +func WithLeanMode() Option { + return func(m *appModel) { + m.leanMode = true + } } // New creates a new Model. -func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initialWorkingDir string, cleanup func()) tea.Model { +func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initialWorkingDir string, cleanup func(), opts ...Option) tea.Model { // Initialize supervisor sv := supervisor.New(spawner) @@ -182,7 +196,6 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi } initialSessionState := service.NewSessionState(initialApp.Session()) - initialChatPage := chat.New(initialApp, initialSessionState) initialEditor := editor.New(initialApp, historyStore) sessID := initialApp.Session().ID @@ -190,12 +203,11 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi supervisor: sv, tabBar: tb, tuiStore: ts, - chatPages: map[string]chat.Page{sessID: initialChatPage}, + chatPages: map[string]chat.Page{}, sessionStates: map[string]*service.SessionState{sessID: initialSessionState}, editors: map[string]editor.Editor{sessID: initialEditor}, application: initialApp, sessionState: initialSessionState, - chatPage: initialChatPage, editor: initialEditor, history: historyStore, pendingRestores: make(map[string]string), @@ -210,6 +222,16 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi dockerDesktop: os.Getenv("TERM_PROGRAM") == "docker_desktop", } + // Apply options + for _, opt := range opts { + opt(m) + } + + // Create initial chat page (after options are applied so leanMode is set) + initialChatPage := chat.New(initialApp, initialSessionState, m.chatPageOpts()...) + m.chatPages[sessID] = initialChatPage + m.chatPage = initialChatPage + // Initialize status bar (pass m as help provider) m.statusBar = statusbar.New(m) @@ -258,12 +280,22 @@ func (m *appModel) reapplyKeyboardEnhancements() { m.editor = editorModel.(editor.Editor) } +// chatPageOpts returns the chat.PageOption slice derived from the current +// appModel configuration (e.g. lean mode). +func (m *appModel) chatPageOpts() []chat.PageOption { + var opts []chat.PageOption + if m.leanMode { + opts = append(opts, chat.WithLeanMode()) + } + return opts +} + // initSessionComponents creates a new chat page, session state, and editor for // the given app and stores them in the per-session maps under tabID. The active // convenience pointers (m.chatPage, m.sessionState, m.editor) are also updated. func (m *appModel) initSessionComponents(tabID string, a *app.App, sess *session.Session) { ss := service.NewSessionState(sess) - cp := chat.New(a, ss) + cp := chat.New(a, ss, m.chatPageOpts()...) ed := editor.New(a, m.history) m.chatPages[tabID] = cp @@ -446,6 +478,16 @@ func (m *appModel) Init() tea.Cmd { // Update handles messages. func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // In lean mode, silently drop messages for features that don't exist. + if m.leanMode { + switch msg.(type) { + case messages.SpawnSessionMsg, messages.SwitchTabMsg, + messages.CloseTabMsg, messages.ReorderTabMsg, + messages.ToggleSidebarMsg: + return m, nil + } + } + switch msg := msg.(type) { // --- Routing & Animation --- @@ -1461,14 +1503,19 @@ func (m *appModel) resizeAll() tea.Cmd { var cmds []tea.Cmd width, height := m.width, m.height + innerWidth := width - appPaddingHorizontal - // Calculate fixed heights - tabBarHeight := m.tabBar.Height() - statusBarHeight := m.statusBar.Height() - resizeHandleHeight := 1 + // Calculate chrome height (everything that isn't content or editor) + chromeHeight := 0 + if m.leanMode { + if m.chatPage.IsWorking() { + chromeHeight = 1 // working indicator line + } + } else { + chromeHeight = m.tabBar.Height() + m.statusBar.Height() + 1 // +1 for resize handle + } // Calculate editor height - innerWidth := width - appPaddingHorizontal minLines := 4 maxLines := max(minLines, (height-6)/2) m.editorLines = max(minLines, min(m.editorLines, maxLines)) @@ -1481,22 +1528,21 @@ func (m *appModel) resizeAll() tea.Cmd { editorRenderedHeight := editorHeight + 1 // Content gets remaining space - m.contentHeight = max(1, height-tabBarHeight-statusBarHeight-resizeHandleHeight-editorRenderedHeight) + m.contentHeight = max(1, height-chromeHeight-editorRenderedHeight) + cmds = append(cmds, m.chatPage.SetSize(width, m.contentHeight)) + + if m.leanMode { + return tea.Batch(cmds...) + } - // Update dialog (uses full window dimensions for overlay positioning) + // Full mode: update overlay components u, cmd := m.dialogMgr.Update(tea.WindowSizeMsg{Width: width, Height: height}) m.dialogMgr = u.(dialog.Manager) cmds = append(cmds, cmd) - // Update chat page (content area) - cmd = m.chatPage.SetSize(width, m.contentHeight) - cmds = append(cmds, cmd) - - // Update completion manager with editor height for popup positioning - m.completions.SetEditorBottom(editorHeight + tabBarHeight) + m.completions.SetEditorBottom(editorHeight + m.tabBar.Height()) m.completions.Update(tea.WindowSizeMsg{Width: width, Height: height}) - // Update notification m.notification.SetSize(width, height) return tea.Batch(cmds...) @@ -1513,6 +1559,11 @@ func (m *appModel) Bindings() []key.Binding { key.WithKeys("ctrl+c"), key.WithHelp("Ctrl+c", "quit"), ) + + if m.leanMode { + return []key.Binding{quitBinding} + } + tabBinding := key.NewBinding( key.WithKeys("tab"), key.WithHelp("Tab", "switch focus"), @@ -1581,7 +1632,7 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Tab bar keys (Ctrl+t, Ctrl+p, Ctrl+n, Ctrl+w) are suppressed during // history search so that ctrl+n/ctrl+p cycle through matches instead. - if !m.editor.IsHistorySearchActive() { + if !m.leanMode && !m.editor.IsHistorySearchActive() { if cmd := m.tabBar.Update(msg); cmd != nil { return m, cmd } @@ -1658,6 +1709,9 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Toggle sidebar (propagates to content view regardless of focus) case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+b"))): + if m.leanMode { + return m, nil + } updated, cmd := m.chatPage.Update(msg) m.chatPage = updated.(chat.Page) return m, cmd @@ -1908,6 +1962,14 @@ const ( // hitTestRegion determines which layout region a Y coordinate falls in. func (m *appModel) hitTestRegion(y int) layoutRegion { + if m.leanMode { + // Lean mode: content | editor (no resize handle, tab bar, or status bar) + if y < m.contentHeight { + return regionContent + } + return regionEditor + } + tabBarHeight := m.tabBar.Height() resizeHandleTop := m.contentHeight @@ -1950,6 +2012,17 @@ func (m *appModel) handleEditorResize(y int) tea.Cmd { return nil } +// renderLeanWorkingIndicator renders a single-line working indicator for lean mode. +func (m *appModel) renderLeanWorkingIndicator() string { + innerWidth := m.width - appPaddingHorizontal + workingText := "Working\u2026" + if queueLen := m.chatPage.QueueLength(); queueLen > 0 { + workingText = fmt.Sprintf("Working\u2026 (%d queued)", queueLen) + } + line := m.workingSpinner.View() + " " + styles.SpinnerDotsHighlightStyle.Render(workingText) + return lipgloss.NewStyle().Padding(0, styles.AppPadding).Width(innerWidth + appPaddingHorizontal).Render(line) +} + // renderResizeHandle renders the draggable separator between content and bottom panel. func (m *appModel) renderResizeHandle(width int) string { if width <= 0 { @@ -2007,7 +2080,7 @@ func (m *appModel) View() tea.View { windowTitle := m.windowTitle() if m.err != nil { - return toFullscreenView(styles.ErrorStyle.Render(m.err.Error()), windowTitle, false) + return toFullscreenView(styles.ErrorStyle.Render(m.err.Error()), windowTitle, false, m.leanMode) } if !m.ready { @@ -2018,12 +2091,26 @@ func (m *appModel) View() tea.View { Render(styles.MutedStyle.Render("Loading…")), windowTitle, false, + m.leanMode, ) } // Content area (messages + sidebar) -- swaps per tab contentView := m.chatPage.View() + // Lean mode: editor appears right after the last message, with empty + // space pushed to the top via bottom-alignment. + if m.leanMode { + viewParts := []string{contentView} + if m.chatPage.IsWorking() { + viewParts = append(viewParts, m.renderLeanWorkingIndicator()) + } + viewParts = append(viewParts, m.editor.View()) + inner := lipgloss.JoinVertical(lipgloss.Top, viewParts...) + baseView := lipgloss.PlaceVertical(m.height, lipgloss.Bottom, inner) + return toFullscreenView(baseView, windowTitle, m.chatPage.IsWorking(), m.leanMode) + } + // Resize handle (between content and bottom panel) resizeHandle := m.renderResizeHandle(m.width) @@ -2074,10 +2161,10 @@ func (m *appModel) View() tea.View { } compositor := lipgloss.NewCompositor(allLayers...) - return toFullscreenView(compositor.Render(), windowTitle, m.chatPage.IsWorking()) + return toFullscreenView(compositor.Render(), windowTitle, m.chatPage.IsWorking(), m.leanMode) } - return toFullscreenView(baseView, windowTitle, m.chatPage.IsWorking()) + return toFullscreenView(baseView, windowTitle, m.chatPage.IsWorking(), m.leanMode) } // windowTitle returns the terminal window title. @@ -2272,9 +2359,9 @@ func getEditorDisplayNameFromEnv(visual, editorEnv string) string { return "$EDITOR" } -func toFullscreenView(content, windowTitle string, working bool) tea.View { +func toFullscreenView(content, windowTitle string, working, leanMode bool) tea.View { view := tea.NewView(content) - view.AltScreen = true + view.AltScreen = !leanMode view.MouseMode = tea.MouseModeCellMotion view.BackgroundColor = styles.Background view.WindowTitle = windowTitle