From 7a09469ee23ce25ae7f43163af2e7c2c6382c7ac 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 ++++- pkg/tui/page/chat/chat.go | 58 ++++++++++++++---- pkg/tui/tui.go | 126 ++++++++++++++++++++++++++++++++------ 4 files changed, 167 insertions(+), 38 deletions(-) diff --git a/cmd/root/new.go b/cmd/root/new.go index 2c5a45cf3..9953d24d1 100644 --- a/cmd/root/new.go +++ b/cmd/root/new.go @@ -80,10 +80,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)) } @@ -106,7 +106,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 fdea45d34..ecfb70c37 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") @@ -275,7 +277,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) { @@ -454,7 +456,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/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 a592a412c..6a6ab828d 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 @@ -1903,6 +1957,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 @@ -1945,6 +2007,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 { @@ -2019,6 +2092,19 @@ func (m *appModel) View() tea.View { // 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()) + } + // Resize handle (between content and bottom panel) resizeHandle := m.renderResizeHandle(m.width)