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)