diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd137b..92511e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Stable Tmux Socket Directory** - Moved tmux sockets from `/tmp/tmux-{uid}/` to `~/.claudio/sockets/` via `TMUX_TMPDIR` to prevent macOS periodic `/tmp` cleanup from killing active tmux servers. `ListClaudioSockets` checks both locations for backward compatibility. ### Changed -- **Ship Experimental Features** - Graduated intelligent naming, terminal support, inline multiplan, inline ultraplan, and grouped instance view from experimental to default. These features are now always enabled without configuration. Only subprocess mode remains experimental. +- **Ship Experimental Features** - Graduated intelligent naming, inline multiplan, inline ultraplan, and grouped instance view from experimental to default. These features are now always enabled without configuration. Only subprocess mode remains experimental. - **Extract `createTmuxSession()` Helper** - Extracted duplicated tmux session setup from `Start()` and `StartWithResume()` into a reusable `createTmuxSession()` method, eliminating ~40 lines of duplication. - **Extract `buildInstanceCallbacks()` Helper** - Consolidated duplicated callback wiring between `newInstanceManager` and `newInstanceManagerWithBackend` into a shared method to prevent sync bugs when adding new callbacks. - **Log tmux session option errors** - Replaced silent `_ =` error discards in `createTmuxSession` and recovery paths with Debug/Warn-level logging for better diagnostics. ### Removed +- **Terminal Pane Feature** - Removed the in-TUI terminal pane. Deleted the `internal/tui/terminal/` package, the `view/terminal.go` view, the `` ` ``/`T`/`Ctrl+Shift+T` key bindings, the `:term`/`:t`/`:termdir` commands, the `TERMINAL` mode indicator/help badge, and the `input.ModeTerminal` routing case. Layout math that previously lived on the terminal manager now uses flat `width`/`height` fields on the TUI model. - **Codex Backend Support** - Removed Codex CLI backend support. Claudio now exclusively uses Claude Code as its AI backend. All Codex-specific configuration (`ai.codex.*`), backend implementation, validation, TUI settings, and documentation have been removed. - **Stop Command (`:x` / `:stop`)** - Removed the `:x` / `:stop` command and its `auto_pr_on_stop` config option. Use `:e` / `:exit` to stop instances, and `claudio pr` for PR creation - **Search Feature** - Removed the output search (`/`) feature from the TUI, including the `internal/tui/search/` package, key bindings (`/`, `n`/`N`, `Ctrl+/`), search bar, match highlighting, mode indicator, and help panel entries (#685) diff --git a/internal/tui/app.go b/internal/tui/app.go index 4e9108b..9961222 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -21,7 +21,6 @@ import ( tuimsg "github.com/Iron-Ham/claudio/internal/tui/msg" "github.com/Iron-Ham/claudio/internal/tui/panel" "github.com/Iron-Ham/claudio/internal/tui/styles" - "github.com/Iron-Ham/claudio/internal/tui/terminal" "github.com/Iron-Ham/claudio/internal/tui/update" "github.com/Iron-Ham/claudio/internal/tui/view" "github.com/Iron-Ham/claudio/internal/ultraplan" @@ -475,23 +474,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: wasReady := m.ready - m.terminalManager.SetSize(msg.Width, msg.Height) + m.width = msg.Width + m.height = msg.Height m.ready = true // Calculate the content area dimensions and resize tmux sessions // Use the configured sidebar width to ensure tmux panels match the UI layout cfg := config.Get() contentWidth, contentHeight := CalculateContentDimensionsWithSidebarWidth( - m.terminalManager.Width(), m.terminalManager.Height(), cfg.TUI.SidebarWidth) + m.width, m.height, cfg.TUI.SidebarWidth) if m.orchestrator != nil && contentWidth > 0 && contentHeight > 0 { m.orchestrator.ResizeAllInstances(contentWidth, contentHeight) } - // Resize terminal pane if visible - if m.terminalManager.IsVisible() { - m.resizeTerminal() - } - // Ensure active instance is still visible after resize m.ensureActiveVisible() @@ -513,10 +508,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tuimsg.TickMsg: // Update outputs from instances m.updateOutputs() - // Update terminal pane output if visible - if m.terminalManager.IsVisible() { - m.updateTerminalOutput() - } // Check for phase changes that need notification (synthesis, consolidation pause) m.checkForPhaseNotification() @@ -941,8 +932,6 @@ func (m *Model) applyCommandResult(result command.Result) { } if result.Quitting != nil { m.quitting = *result.Quitting - // Cleanup terminal pane if running - m.cleanupTerminal() } if result.AddingTask != nil { m.addingTask = *result.AddingTask @@ -960,56 +949,6 @@ func (m *Model) applyCommandResult(result command.Result) { m.filterMode = *result.FilterMode } - // Handle terminal-related state changes - if result.EnterTerminalMode { - m.enterTerminalMode() - } - if result.ToggleTerminal { - sessionID := "" - if m.orchestrator != nil { - sessionID = m.orchestrator.SessionID() - } - m.toggleTerminalVisibility(sessionID) - if m.terminalManager.IsVisible() { - m.infoMessage = "Terminal pane opened. Press [:t] to focus, [`] to hide." - } else { - m.infoMessage = "Terminal pane closed." - } - } - if result.TerminalDirMode != nil { - newMode := terminal.DirMode(*result.TerminalDirMode) - currentMode := m.terminalManager.DirMode() - if newMode != currentMode { - m.terminalManager.SetDirMode(newMode) - process := m.terminalManager.Process() - if process != nil && process.IsRunning() { - targetDir := m.getTerminalDir() - if err := process.ChangeDirectory(targetDir); err != nil { - m.errorMessage = "Failed to change directory: " + err.Error() - } else { - if newMode == terminal.DirWorktree { - m.infoMessage = "Terminal: switched to worktree" - } else { - m.infoMessage = "Terminal: switched to invocation directory" - } - } - } else { - if newMode == terminal.DirWorktree { - m.infoMessage = "Terminal will use worktree when opened." - } else { - m.infoMessage = "Terminal will use invocation directory when opened." - } - } - } else { - // Already in the requested mode - if newMode == terminal.DirWorktree { - m.infoMessage = "Terminal is already in worktree mode." - } else { - m.infoMessage = "Terminal is already in invocation directory mode." - } - } - } - // Handle active tab adjustment after instance removal if result.ActiveTabAdjustment != 0 { if m.activeTab >= m.instanceCount() { @@ -1229,15 +1168,11 @@ func (m Model) View() string { b.WriteString(header) b.WriteString("\n") - // Get pane dimensions, accounting for dynamic footer elements - dims := m.terminalManager.GetPaneDimensions(m.calculateExtraFooterLines()) + // Calculate main area dimensions, accounting for dynamic footer elements cfg := config.Get() - effectiveSidebarWidth := CalculateEffectiveSidebarWidthWithConfig(dims.TerminalWidth, cfg.TUI.SidebarWidth) - mainContentWidth := dims.TerminalWidth - effectiveSidebarWidth - 3 // 3 for gap between panels - - // Main area height is pre-calculated by terminal manager - // (accounts for header, footer, and terminal pane) - mainAreaHeight := dims.MainAreaHeight + effectiveSidebarWidth := CalculateEffectiveSidebarWidthWithConfig(m.width, cfg.TUI.SidebarWidth) + mainContentWidth := m.width - effectiveSidebarWidth - 3 // 3 for gap between panels + mainAreaHeight := m.mainAreaHeight(m.calculateExtraFooterLines()) // Sidebar + Content area (horizontal layout) // Use view component for sidebar rendering - handles all modes including ultraplan @@ -1275,12 +1210,6 @@ func (m Model) View() string { mainArea := lipgloss.JoinHorizontal(lipgloss.Top, sidebarStyled, " ", contentStyled) b.WriteString(mainArea) - // Terminal pane (if visible) - if m.terminalManager.IsVisible() { - b.WriteString("\n") - b.WriteString(m.renderTerminalPane()) - } - // Info or error message if any if m.infoMessage != "" { b.WriteString("\n") @@ -1331,11 +1260,10 @@ func (m Model) renderUnifiedHeader() string { // Build mode indicator state modeState := &view.ModeIndicatorState{ - CommandMode: m.commandMode, - FilterMode: m.filterMode, - InputMode: m.inputMode, - TerminalFocused: m.terminalManager.IsFocused(), - AddingTask: m.addingTask, + CommandMode: m.commandMode, + FilterMode: m.filterMode, + InputMode: m.inputMode, + AddingTask: m.addingTask, } // Get the workflow status and mode indicator @@ -1343,7 +1271,7 @@ func (m Model) renderUnifiedHeader() string { modeIndicator := view.RenderModeIndicator(modeState) // Calculate available width for layout - termWidth := m.terminalManager.Width() + termWidth := m.width // If no workflow status and no mode indicator, render simple header if workflowStatus == "" && modeIndicator == "" { @@ -1403,40 +1331,6 @@ func (m Model) buildWorkflowStatusState() *view.WorkflowStatusState { } } -// renderTerminalPane renders the terminal pane at the bottom of the screen. -func (m Model) renderTerminalPane() string { - dims := m.terminalManager.GetPaneDimensions(m.calculateExtraFooterLines()) - if dims.TerminalPaneHeight == 0 { - return "" - } - - // Build the terminal state for the view - process := m.terminalManager.Process() - state := view.TerminalState{ - Output: m.terminalManager.Output(), - TerminalMode: m.terminalManager.IsFocused(), - InvocationDir: m.terminalManager.GetDir(nil), // Pass nil to get invocation dir - IsWorktreeMode: m.terminalManager.DirMode() == terminal.DirWorktree, - } - - // Set current directory - if process != nil { - state.CurrentDir = process.CurrentDir() - } else { - state.CurrentDir = state.InvocationDir - } - - // Set instance ID if in worktree mode - if state.IsWorktreeMode { - if inst := m.activeInstance(); inst != nil { - state.InstanceID = inst.ID - } - } - - termView := view.NewTerminalView(dims.TerminalWidth, dims.TerminalPaneHeight) - return termView.Render(state) -} - // renderContent renders the main content area func (m Model) renderContent(width int) string { if m.addingTask { @@ -1593,7 +1487,7 @@ func (m Model) renderHelpPanel(width int) string { helpPanel := panel.NewHelpPanel() state := &panel.RenderState{ Width: width - 4, // Account for content box padding - Height: m.terminalManager.Height() - 4, + Height: m.height - 4, ScrollOffset: m.helpScroll, Theme: styles.NewTheme(), } @@ -1609,7 +1503,7 @@ func (m Model) renderDiffPanel(width int) string { diffPanel := panel.NewDiffPanel() state := &panel.RenderState{ Width: width - 4, // Account for content box padding - Height: m.terminalManager.Height() - 4, + Height: m.height - 4, ScrollOffset: m.diffScroll, Theme: styles.NewTheme(), ActiveInstance: m.activeInstance(), @@ -1640,26 +1534,13 @@ func (m Model) calculateExtraFooterLines() int { // buildHelpBarState creates the view.HelpBarState from the current model state. func (m Model) buildHelpBarState() *view.HelpBarState { - state := &view.HelpBarState{ + return &view.HelpBarState{ CommandMode: m.commandMode, CommandBuffer: m.commandBuffer, InputMode: m.inputMode, ShowDiff: m.showDiff, FilterMode: m.filterMode, } - - // Terminal manager may be nil in tests - if m.terminalManager != nil { - state.TerminalFocused = m.terminalManager.IsFocused() - state.TerminalVisible = m.terminalManager.IsVisible() - if m.terminalManager.DirMode() == terminal.DirWorktree { - state.TerminalDirMode = "worktree" - } else { - state.TerminalDirMode = "invoke" - } - } - - return state } // renderCommandModeHelp renders the help bar when in command mode. @@ -1683,7 +1564,7 @@ func (m Model) renderStatsPanel(width int) string { // Build render state for the panel state := &panel.RenderState{ Width: width, - Height: m.terminalManager.Height(), + Height: m.height, Theme: styles.NewTheme(), CostWarningThreshold: cfg.Resources.CostWarningThreshold, CostLimit: cfg.Resources.CostLimit, diff --git a/internal/tui/command/handler.go b/internal/tui/command/handler.go index 597c589..133809e 100644 --- a/internal/tui/command/handler.go +++ b/internal/tui/command/handler.go @@ -30,7 +30,6 @@ type Dependencies interface { InstanceCount() int // State queries - IsTerminalVisible() bool IsDiffVisible() bool GetDiffContent() string IsUltraPlanMode() bool @@ -89,11 +88,6 @@ type Result struct { ActiveTabAdjustment int EnsureActiveVisible bool - // Terminal-related state changes - EnterTerminalMode bool - ToggleTerminal bool // signals that terminal visibility should be toggled - TerminalDirMode *int // 0 = invocation, 1 = worktree - // Mode transition - Triple-Shot StartTripleShot *bool // Request to switch to triple-shot mode @@ -273,18 +267,6 @@ func (h *Handler) registerCommands() { h.argCommands["r"] = cmdPRWithArgs h.argCommands["pr"] = cmdPRWithArgs - // Terminal pane commands - h.commands["t"] = cmdTerminalFocus - h.commands["term"] = cmdTerminal - h.commands["terminal"] = cmdTerminal - h.commands["termdir worktree"] = cmdTerminalDirWorktree - h.commands["termdir wt"] = cmdTerminalDirWorktree - h.commands["termdir project"] = cmdTerminalDirProject - h.commands["termdir proj"] = cmdTerminalDirProject - // Legacy aliases for backward compatibility - h.commands["termdir invoke"] = cmdTerminalDirProject - h.commands["termdir invocation"] = cmdTerminalDirProject - // Ultraplan commands h.commands["cancel"] = cmdUltraPlanCancel h.argCommands["ultraplan"] = cmdUltraPlan @@ -360,7 +342,6 @@ func (h *Handler) buildCategories() { { Name: "Terminal", Commands: []CommandInfo{ - {ShortKey: "t", LongKey: "term", Description: "Focus/toggle terminal pane", Category: "terminal"}, {ShortKey: "", LongKey: "tmux", Description: "Show tmux attach command", Category: "terminal"}, }, }, @@ -776,30 +757,6 @@ func cmdFilter(_ Dependencies) Result { return Result{FilterMode: &filterMode} } -func cmdTerminal(_ Dependencies) Result { - return Result{ToggleTerminal: true} -} - -func cmdTerminalFocus(deps Dependencies) Result { - if deps.IsTerminalVisible() { - return Result{ - EnterTerminalMode: true, - InfoMessage: "Terminal focused. Press Ctrl+] to exit.", - } - } - return Result{ErrorMessage: "Terminal not visible. Use :term to open it first."} -} - -func cmdTerminalDirWorktree(_ Dependencies) Result { - mode := 1 // TerminalDirWorktree - return Result{TerminalDirMode: &mode} -} - -func cmdTerminalDirProject(_ Dependencies) Result { - mode := 0 // TerminalDirProject (the directory where Claudio was started) - return Result{TerminalDirMode: &mode} -} - func cmdTmux(deps Dependencies) Result { inst := deps.ActiveInstance() if inst == nil { diff --git a/internal/tui/command/handler_test.go b/internal/tui/command/handler_test.go index 3dfda3e..f5372be 100644 --- a/internal/tui/command/handler_test.go +++ b/internal/tui/command/handler_test.go @@ -34,7 +34,6 @@ type mockDeps struct { session *orchestrator.Session activeInstance *orchestrator.Instance instanceCount int - terminalVisible bool diffVisible bool diffContent string ultraPlanMode bool @@ -56,7 +55,6 @@ func (m *mockDeps) GetOrchestrator() *orchestrator.Orchestrator { return m.orche func (m *mockDeps) GetSession() *orchestrator.Session { return m.session } func (m *mockDeps) ActiveInstance() *orchestrator.Instance { return m.activeInstance } func (m *mockDeps) InstanceCount() int { return m.instanceCount } -func (m *mockDeps) IsTerminalVisible() bool { return m.terminalVisible } func (m *mockDeps) IsDiffVisible() bool { return m.diffVisible } func (m *mockDeps) GetDiffContent() string { return m.diffContent } func (m *mockDeps) IsUltraPlanMode() bool { return m.ultraPlanMode } @@ -166,7 +164,7 @@ func TestCategoriesContainAllShortcuts(t *testing.T) { } // Verify key shortcuts are documented - expectedShortcuts := []string{"s", "e", "p", "R", "a", "D", "C", "d", "m", "f", "t", "r", "h", "q", "q!"} + expectedShortcuts := []string{"s", "e", "p", "R", "a", "D", "C", "d", "m", "f", "r", "h", "q", "q!"} for _, key := range expectedShortcuts { if !shortKeys[key] { t.Errorf("shortcut %q not found in categories", key) @@ -727,106 +725,6 @@ func TestDiffCommand(t *testing.T) { }) } -func TestTerminalCommands(t *testing.T) { - t.Run("term toggles terminal visibility", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("term", deps) - if !result.ToggleTerminal { - t.Error("expected ToggleTerminal to be true") - } - }) - - t.Run("terminal alias", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("terminal", deps) - if !result.ToggleTerminal { - t.Error("expected ToggleTerminal to be true") - } - }) - - t.Run("t focuses terminal when visible", func(t *testing.T) { - h := New() - deps := newMockDeps() - deps.terminalVisible = true - - result := h.Execute("t", deps) - if !result.EnterTerminalMode { - t.Error("expected EnterTerminalMode to be true") - } - if result.InfoMessage == "" { - t.Error("expected info message about terminal focus") - } - }) - - t.Run("t shows error when terminal not visible", func(t *testing.T) { - h := New() - deps := newMockDeps() - deps.terminalVisible = false - - result := h.Execute("t", deps) - if result.EnterTerminalMode { - t.Error("expected EnterTerminalMode to be false") - } - if result.ErrorMessage == "" { - t.Error("expected error message when terminal not visible") - } - }) - - t.Run("termdir worktree sets mode", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("termdir worktree", deps) - if result.TerminalDirMode == nil || *result.TerminalDirMode != 1 { - t.Error("expected TerminalDirMode to be set to 1 (worktree)") - } - }) - - t.Run("termdir wt alias", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("termdir wt", deps) - if result.TerminalDirMode == nil || *result.TerminalDirMode != 1 { - t.Error("expected TerminalDirMode to be set to 1 (worktree)") - } - }) - - t.Run("termdir project sets mode", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("termdir project", deps) - if result.TerminalDirMode == nil || *result.TerminalDirMode != 0 { - t.Error("expected TerminalDirMode to be set to 0 (project)") - } - }) - - t.Run("termdir proj alias", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("termdir proj", deps) - if result.TerminalDirMode == nil || *result.TerminalDirMode != 0 { - t.Error("expected TerminalDirMode to be set to 0 (project)") - } - }) - - t.Run("termdir invoke legacy alias", func(t *testing.T) { - h := New() - deps := newMockDeps() - - result := h.Execute("termdir invoke", deps) - if result.TerminalDirMode == nil || *result.TerminalDirMode != 0 { - t.Error("expected TerminalDirMode to be set to 0 (project)") - } - }) -} - func TestInstanceControlCommandsNoInstance(t *testing.T) { // All instance control commands should return "No instance selected" when no instance commands := []string{ @@ -978,11 +876,6 @@ func TestAllCommandsRecognized(t *testing.T) { "f", "F", "filter", // Utilities "tmux", "r", "pr", - // Terminal - "t", "term", "terminal", - "termdir worktree", "termdir wt", - "termdir project", "termdir proj", - "termdir invoke", "termdir invocation", // Ultraplan "cancel", // Plan mode diff --git a/internal/tui/command_test.go b/internal/tui/command_test.go index 6afd354..710959e 100644 --- a/internal/tui/command_test.go +++ b/internal/tui/command_test.go @@ -5,16 +5,14 @@ import ( "testing" "github.com/Iron-Ham/claudio/internal/tui/command" - "github.com/Iron-Ham/claudio/internal/tui/terminal" tea "github.com/charmbracelet/bubbletea" ) -// testModel creates a Model with the commandHandler and terminalManager initialized for testing. +// testModel creates a Model with the commandHandler initialized for testing. // This is necessary because tests construct Model directly instead of using NewModel. func testModel() Model { return Model{ - commandHandler: command.New(), - terminalManager: terminal.NewManager(), + commandHandler: command.New(), } } @@ -524,7 +522,7 @@ func TestCommandAliasesRequiringInstance(t *testing.T) { "D", "remove", "kill", // Utilities - "tmux", // Note: :t is now terminal focus, not an alias for tmux + "tmux", "r", "pr", } @@ -548,66 +546,6 @@ func TestCommandAliasesRequiringInstance(t *testing.T) { } } -func TestTerminalFocusCommand(t *testing.T) { - t.Run("t command attempts focus when terminal visible", func(t *testing.T) { - // Note: enterTerminalMode() requires a running terminal process to actually - // set focused=true. This test verifies the command path is correct. - m := testModel() - m.terminalManager.SetLayout(terminal.LayoutVisible) - result, _ := m.executeCommand("t") - model := result.(Model) - - // Should show info message (even without running process) - if model.infoMessage == "" { - t.Error("expected info message about terminal focus") - } - if model.errorMessage != "" { - t.Errorf("unexpected error message: %q", model.errorMessage) - } - }) - - t.Run("t command shows error when terminal not visible", func(t *testing.T) { - m := testModel() - // Terminal manager starts with LayoutHidden by default - result, _ := m.executeCommand("t") - model := result.(Model) - - if model.terminalManager.IsFocused() { - t.Error("expected focused to remain false when terminal not visible") - } - if model.errorMessage == "" { - t.Error("expected error message when terminal not visible") - } - }) - - t.Run("t key in normal mode does NOT enter terminal mode", func(t *testing.T) { - // This is a regression test to ensure 't' key doesn't trigger terminal mode - // directly - it should only work via command mode (:t) - m := testModel() - m.terminalManager.SetLayout(terminal.LayoutVisible) - m.commandMode = false - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}} - result, _ := m.handleKeypress(msg) - model := result.(Model) - - if model.terminalManager.IsFocused() { - t.Error("expected focused to remain false - 't' key should not trigger terminal mode in normal mode") - } - }) - - t.Run("t command is recognized as valid command", func(t *testing.T) { - m := testModel() - result, _ := m.executeCommand("t") - model := result.(Model) - - // Should NOT be an unknown command error - if model.errorMessage != "" && len(model.errorMessage) >= 7 && model.errorMessage[:7] == "Unknown" { - t.Error("command 't' was not recognized") - } - }) -} - func TestRenderCommandModeHelp(t *testing.T) { t.Run("renders colon prompt", func(t *testing.T) { m := Model{ @@ -732,10 +670,8 @@ func TestCommandModePriorityOverOtherModes(t *testing.T) { func TestHelpPanelContainsAllCommands(t *testing.T) { m := testModel() // Set terminal size large enough to show all help content without scrolling - // The help panel truncates based on terminal height, so we need enough height - // to display all sections (Navigation, Instance Control, Instance Management, - // View Commands, Terminal Pane, Input Mode, Search, Inline Planning, Session) - m.terminalManager.SetSize(120, 200) + m.width = 120 + m.height = 200 // Render the help panel helpContent := m.renderHelpPanel(100) diff --git a/internal/tui/input/router.go b/internal/tui/input/router.go index ab81820..5af418c 100644 --- a/internal/tui/input/router.go +++ b/internal/tui/input/router.go @@ -23,9 +23,6 @@ const ( // ModeInput forwards keys to the active instance's tmux session. ModeInput - // ModeTerminal forwards keys to the terminal pane's tmux session. - ModeTerminal - // ModeTaskInput handles task description entry. ModeTaskInput @@ -47,8 +44,6 @@ func (m Mode) String() string { return "filter" case ModeInput: return "input" - case ModeTerminal: - return "terminal" case ModeTaskInput: return "task-input" case ModePlanEditor: @@ -300,8 +295,6 @@ func (r *Router) effectiveMode() Mode { return ModeFilter case ModeInput: return ModeInput - case ModeTerminal: - return ModeTerminal case ModeTaskInput: return ModeTaskInput case ModeCommand: @@ -330,12 +323,7 @@ func (r *Router) ShouldExitModeOnEscape() bool { // ShouldExitModeOnCtrlBracket returns true if the current mode exits on Ctrl+]. func (r *Router) ShouldExitModeOnCtrlBracket() bool { - switch r.mode { - case ModeInput, ModeTerminal: - return true - default: - return false - } + return r.mode == ModeInput } // IsBufferedMode returns true if the current mode uses a text buffer. @@ -350,12 +338,7 @@ func (r *Router) IsBufferedMode() bool { // IsForwardingMode returns true if keys should be forwarded to tmux. func (r *Router) IsForwardingMode() bool { - switch r.mode { - case ModeInput, ModeTerminal: - return true - default: - return false - } + return r.mode == ModeInput } // TransitionToNormal transitions back to normal mode. @@ -380,11 +363,6 @@ func (r *Router) TransitionToInput() { r.mode = ModeInput } -// TransitionToTerminal enters terminal mode (terminal pane forwarding). -func (r *Router) TransitionToTerminal() { - r.mode = ModeTerminal -} - // TransitionToTaskInput enters task input mode. func (r *Router) TransitionToTaskInput() { r.mode = ModeTaskInput diff --git a/internal/tui/input/router_test.go b/internal/tui/input/router_test.go index 3f637ad..e17d98d 100644 --- a/internal/tui/input/router_test.go +++ b/internal/tui/input/router_test.go @@ -15,7 +15,6 @@ func TestMode_String(t *testing.T) { {ModeCommand, "command"}, {ModeFilter, "filter"}, {ModeInput, "input"}, - {ModeTerminal, "terminal"}, {ModeTaskInput, "task-input"}, {ModePlanEditor, "plan-editor"}, {ModeUltraPlan, "ultra-plan"}, @@ -47,7 +46,7 @@ func TestNewRouter(t *testing.T) { func TestRouter_SetMode(t *testing.T) { r := NewRouter() - modes := []Mode{ModeCommand, ModeFilter, ModeInput, ModeTerminal, ModeTaskInput, ModeNormal} + modes := []Mode{ModeCommand, ModeFilter, ModeInput, ModeTaskInput, ModeNormal} for _, mode := range modes { r.SetMode(mode) @@ -230,7 +229,6 @@ func TestRouter_Transitions(t *testing.T) { {"TransitionToCommand", r.TransitionToCommand, ModeCommand}, {"TransitionToFilter", r.TransitionToFilter, ModeFilter}, {"TransitionToInput", r.TransitionToInput, ModeInput}, - {"TransitionToTerminal", r.TransitionToTerminal, ModeTerminal}, {"TransitionToTaskInput", r.TransitionToTaskInput, ModeTaskInput}, {"TransitionToNormal", r.TransitionToNormal, ModeNormal}, } @@ -259,7 +257,6 @@ func TestRouter_TransitionClearsBuffer(t *testing.T) { {"TransitionToTaskInput", (*Router).TransitionToTaskInput, true}, {"TransitionToFilter", (*Router).TransitionToFilter, false}, {"TransitionToInput", (*Router).TransitionToInput, false}, - {"TransitionToTerminal", (*Router).TransitionToTerminal, false}, } for _, tt := range tests { @@ -287,7 +284,6 @@ func TestRouter_ShouldExitModeOnEscape(t *testing.T) { {ModeCommand, true}, {ModeFilter, true}, {ModeInput, false}, - {ModeTerminal, false}, {ModeTaskInput, true}, {ModePlanEditor, false}, {ModeUltraPlan, false}, @@ -315,7 +311,6 @@ func TestRouter_ShouldExitModeOnCtrlBracket(t *testing.T) { {ModeCommand, false}, {ModeFilter, false}, {ModeInput, true}, - {ModeTerminal, true}, {ModeTaskInput, false}, {ModePlanEditor, false}, {ModeUltraPlan, false}, @@ -343,7 +338,6 @@ func TestRouter_IsBufferedMode(t *testing.T) { {ModeCommand, true}, {ModeFilter, true}, {ModeInput, false}, - {ModeTerminal, false}, {ModeTaskInput, true}, {ModePlanEditor, false}, {ModeUltraPlan, false}, @@ -371,7 +365,6 @@ func TestRouter_IsForwardingMode(t *testing.T) { {ModeCommand, false}, {ModeFilter, false}, {ModeInput, true}, - {ModeTerminal, true}, {ModeTaskInput, false}, {ModePlanEditor, false}, {ModeUltraPlan, false}, @@ -495,7 +488,6 @@ func TestRouter_EffectiveMode_Priority(t *testing.T) { r.RegisterHandler(ModeNormal, makeHandler(ModeNormal)) r.RegisterHandler(ModeFilter, makeHandler(ModeFilter)) r.RegisterHandler(ModeInput, makeHandler(ModeInput)) - r.RegisterHandler(ModeTerminal, makeHandler(ModeTerminal)) r.RegisterHandler(ModeTaskInput, makeHandler(ModeTaskInput)) r.RegisterHandler(ModeCommand, makeHandler(ModeCommand)) r.RegisterHandler(ModePlanEditor, makeHandler(ModePlanEditor)) @@ -517,13 +509,6 @@ func TestRouter_EffectiveMode_Priority(t *testing.T) { t.Errorf("Input mode: called %v, want ModeInput", calledMode) } - // Test terminal mode - r.SetMode(ModeTerminal) - r.Route(msg) - if calledMode != ModeTerminal { - t.Errorf("Terminal mode: called %v, want ModeTerminal", calledMode) - } - // Test task input mode r.SetMode(ModeTaskInput) r.Route(msg) diff --git a/internal/tui/keyhandler.go b/internal/tui/keyhandler.go index d50afa7..b64fff9 100644 --- a/internal/tui/keyhandler.go +++ b/internal/tui/keyhandler.go @@ -31,11 +31,6 @@ func (m Model) handleKeypress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleInputMode(msg) } - // Handle terminal mode - forward keys to the terminal pane's tmux session - if m.terminalManager.IsFocused() { - return m.handleTerminalMode(msg) - } - // Handle task input mode if m.addingTask { return m.handleTaskInput(msg) @@ -80,32 +75,6 @@ func (m Model) handleInputMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// ----------------------------------------------------------------------------- -// Terminal Mode Handler (terminal pane passthrough) -// ----------------------------------------------------------------------------- - -// handleTerminalMode forwards keys to the terminal pane's tmux session. -func (m Model) handleTerminalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Ctrl+] exits terminal mode (same escape as input mode) - if msg.Type == tea.KeyCtrlCloseBracket { - m.exitTerminalMode() - return m, nil - } - - // Forward the key to the terminal pane's tmux session - // Check if this is a paste operation - if msg.Paste && msg.Type == tea.KeyRunes && len(msg.Runes) > 0 { - if err := m.terminalManager.SendPaste(string(msg.Runes)); err != nil { - if m.logger != nil { - m.logger.Warn("failed to paste to terminal", "error", err) - } - } - } else { - m.terminalManager.SendKey(msg) - } - return m, nil -} - // ----------------------------------------------------------------------------- // Task Input Mode Handler // ----------------------------------------------------------------------------- @@ -511,12 +480,6 @@ func (m Model) handleNormalModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter", "i": return m.handleEnterInputMode() - case "`", "T": - return m.handleToggleTerminal() - - case "ctrl+shift+t": - return m.handleSwitchTerminalDir() - case "esc": return m.handleEscape() @@ -612,7 +575,6 @@ func (m Model) handleNextInstance() (tea.Model, tea.Cmd) { if newTab >= 0 { m.switchToInstance(newTab) m.ensureActiveVisible() - m.updateTerminalOnInstanceChange() // Log focus change if m.logger != nil { if inst := m.activeInstance(); inst != nil { @@ -659,7 +621,6 @@ func (m Model) handlePrevInstance() (tea.Model, tea.Cmd) { if newTab >= 0 { m.switchToInstance(newTab) m.ensureActiveVisible() - m.updateTerminalOnInstanceChange() // Log focus change if m.logger != nil { if inst := m.activeInstance(); inst != nil { @@ -683,24 +644,6 @@ func (m Model) handleEnterInputMode() (tea.Model, tea.Cmd) { return m, nil } -// handleToggleTerminal toggles terminal pane visibility. -func (m Model) handleToggleTerminal() (tea.Model, tea.Cmd) { - sessionID := "" - if m.orchestrator != nil { - sessionID = m.orchestrator.SessionID() - } - m.toggleTerminalVisibility(sessionID) - return m, nil -} - -// handleSwitchTerminalDir switches terminal directory mode. -func (m Model) handleSwitchTerminalDir() (tea.Model, tea.Cmd) { - if m.terminalManager.IsVisible() { - m.switchTerminalDir() - } - return m, nil -} - // handleEscape handles the escape key in normal mode. func (m Model) handleEscape() (tea.Model, tea.Cmd) { // Close diff panel if open @@ -898,7 +841,7 @@ func (m Model) handleGoToTop() (tea.Model, tea.Cmd) { func (m Model) handleGoToBottom() (tea.Model, tea.Cmd) { if m.showDiff { lines := strings.Split(m.diffContent, "\n") - maxLines := m.terminalManager.Height() - 14 + maxLines := m.height - 14 if maxLines < 5 { maxLines = 5 } @@ -1044,9 +987,8 @@ func (m Model) openBranchSelector() (tea.Model, tea.Cmd) { m.branchScrollOffset = 0 // Calculate visible height for branch selector (reserve space for UI elements) - dims := m.terminalManager.GetPaneDimensions(m.calculateExtraFooterLines()) // Reserve: search line, scroll indicators, count line, padding - m.branchSelectorHeight = dims.MainAreaHeight - 10 + m.branchSelectorHeight = m.mainAreaHeight(m.calculateExtraFooterLines()) - 10 if m.branchSelectorHeight < 5 { m.branchSelectorHeight = 5 } @@ -1283,8 +1225,6 @@ func (m Model) CurrentInputMode() input.Mode { return input.ModeFilter case m.inputMode: return input.ModeInput - case m.terminalManager.IsFocused(): - return input.ModeTerminal case m.addingTask: return input.ModeTaskInput case m.commandMode: diff --git a/internal/tui/model.go b/internal/tui/model.go index f2f9626..77bcf99 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -12,7 +12,7 @@ import ( "github.com/Iron-Ham/claudio/internal/tui/filter" "github.com/Iron-Ham/claudio/internal/tui/input" "github.com/Iron-Ham/claudio/internal/tui/output" - "github.com/Iron-Ham/claudio/internal/tui/terminal" + "github.com/Iron-Ham/claudio/internal/tui/styles" "github.com/Iron-Ham/claudio/internal/tui/view" tea "github.com/charmbracelet/bubbletea" ) @@ -34,19 +34,6 @@ const ( sidebarLinesPerItemGrouped = 3 ) -// modelInstanceProvider adapts the Model to the terminal.ActiveInstanceProvider interface. -type modelInstanceProvider struct { - model *Model -} - -// WorktreePath returns the worktree path of the active instance. -func (p modelInstanceProvider) WorktreePath() string { - if inst := p.model.activeInstance(); inst != nil { - return inst.WorktreePath - } - return "" -} - // PlanEditorState holds the state for the interactive plan editor type PlanEditorState struct { // active indicates whether the plan editor is currently shown @@ -261,8 +248,9 @@ type Model struct { // Input routing inputRouter *input.Router - // Terminal pane manager (owns dimensions and layout calculations) - terminalManager *terminal.Manager + // Terminal window dimensions (updated on tea.WindowSizeMsg). + width int + height int // Ultra-plan mode (nil if not in ultra-plan mode) ultraPlan *UltraPlanState @@ -624,32 +612,19 @@ func NewModel(orch *orchestrator.Orchestrator, session *orchestrator.Session, lo tuiLogger = logger.WithPhase("tui") } - // Get invocation directory from orchestrator - invocationDir := "" - if orch != nil { - invocationDir = orch.BaseDir() - } - - // Create terminal manager with configuration - termMgr := terminal.NewManagerWithConfig(terminal.ManagerConfig{ - InvocationDir: invocationDir, - Logger: tuiLogger, - }) - outputFilter := filter.New() outputManager := output.NewManager() outputManager.SetFilterFunc(outputFilter.Apply) return Model{ - orchestrator: orch, - session: session, - logger: tuiLogger, - startTime: time.Now(), - commandHandler: command.New(), - inputRouter: input.NewRouter(), - outputManager: outputManager, - terminalManager: termMgr, - outputFilter: outputFilter, + orchestrator: orch, + session: session, + logger: tuiLogger, + startTime: time.Now(), + commandHandler: command.New(), + inputRouter: input.NewRouter(), + outputManager: outputManager, + outputFilter: outputFilter, } } @@ -671,8 +646,6 @@ func (m *Model) syncRouterState() { m.inputRouter.SetMode(input.ModeFilter) case m.inputMode: m.inputRouter.SetMode(input.ModeInput) - case m.terminalManager.IsFocused(): - m.inputRouter.SetMode(input.ModeTerminal) case m.addingTask: m.inputRouter.SetMode(input.ModeTaskInput) case m.commandMode: @@ -896,10 +869,24 @@ func (m Model) findInstanceIndexByID(id string) int { return -1 } +// mainAreaHeight returns the height available for the main content area +// (sidebar + content), accounting for header, footer, and any extra +// dynamic footer lines such as error messages or conflict warnings. +func (m Model) mainAreaHeight(extraFooterLines int) int { + if extraFooterLines < 0 { + extraFooterLines = 0 + } + h := m.height - styles.HeaderFooterReserved - extraFooterLines + const minMainAreaHeight = 10 + if h < minMainAreaHeight { + h = minMainAreaHeight + } + return h +} + // sidebarVisibleItemCount returns the number of items that can fit in the sidebar viewport. func (m *Model) sidebarVisibleItemCount() int { - dims := m.terminalManager.GetPaneDimensions(m.calculateExtraFooterLines()) - availableLines := max(dims.MainAreaHeight-sidebarReservedLines, 3) + availableLines := max(m.mainAreaHeight(m.calculateExtraFooterLines())-sidebarReservedLines, 3) linesPerItem := sidebarLinesPerItemFlat if m.sidebarMode == view.SidebarModeGrouped { @@ -1017,12 +1004,10 @@ func (m Model) calculateSidebarMaxScrollOffset() int { // getOutputMaxLines returns the maximum number of lines visible in the output area func (m Model) getOutputMaxLines() int { - dims := m.terminalManager.GetPaneDimensions(m.calculateExtraFooterLines()) - // Calculate overhead based on the active instance's actual properties overhead := m.calculateInstanceOverhead() - maxLines := dims.MainAreaHeight - overhead + maxLines := m.mainAreaHeight(m.calculateExtraFooterLines()) - overhead if maxLines < 5 { maxLines = 5 } @@ -1210,85 +1195,6 @@ func isWordChar(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' } -// ----------------------------------------------------------------------------- -// Terminal pane helper methods -// ----------------------------------------------------------------------------- - -// IsTerminalMode returns true if the terminal pane has input focus. -func (m Model) IsTerminalMode() bool { - return m.terminalManager.IsFocused() -} - -// IsTerminalVisible returns true if the terminal pane is visible. -func (m Model) IsTerminalVisible() bool { - return m.terminalManager.IsVisible() -} - -// TerminalPaneHeight returns the current terminal pane height (0 if hidden). -func (m Model) TerminalPaneHeight() int { - dims := m.terminalManager.GetPaneDimensions(0) - return dims.TerminalPaneHeight -} - -// getTerminalDir returns the directory path for the terminal based on current mode. -func (m Model) getTerminalDir() string { - return m.terminalManager.GetDir(modelInstanceProvider{model: &m}) -} - -// toggleTerminalVisibility toggles the terminal pane on or off. -// If turning on and process doesn't exist, it will be created lazily. -func (m *Model) toggleTerminalVisibility(sessionID string) { - errMsg, warnMsg := m.terminalManager.Toggle(sessionID) - if errMsg != "" { - m.errorMessage = errMsg - } else if warnMsg != "" { - m.infoMessage = warnMsg - } -} - -// enterTerminalMode enters terminal input mode (keys go to terminal). -func (m *Model) enterTerminalMode() { - m.terminalManager.EnterMode() -} - -// exitTerminalMode exits terminal input mode. -func (m *Model) exitTerminalMode() { - m.terminalManager.ExitMode() -} - -// switchTerminalDir toggles between worktree and invocation directory modes. -func (m *Model) switchTerminalDir() { - infoMsg, errMsg := m.terminalManager.SwitchDir(modelInstanceProvider{model: m}) - if errMsg != "" { - m.errorMessage = errMsg - } else if infoMsg != "" { - m.infoMessage = infoMsg - } -} - -// updateTerminalOutput captures current terminal output. -func (m *Model) updateTerminalOutput() { - m.terminalManager.UpdateOutput() -} - -// resizeTerminal updates the terminal dimensions. -func (m *Model) resizeTerminal() { - m.terminalManager.Resize() -} - -// cleanupTerminal stops the terminal process (called on quit). -func (m *Model) cleanupTerminal() { - m.terminalManager.Cleanup() -} - -// updateTerminalOnInstanceChange updates terminal directory if in worktree mode. -// Called when the active instance changes. -func (m *Model) updateTerminalOnInstanceChange() { - if errMsg := m.terminalManager.UpdateOnInstanceChange(modelInstanceProvider{model: m}); errMsg != "" { - m.errorMessage = errMsg - } -} - // ----------------------------------------------------------------------------- // DashboardState interface implementation // These methods implement the view.DashboardState interface, allowing the Model @@ -1310,14 +1216,14 @@ func (m Model) SidebarScrollOffset() int { return m.sidebarScrollOffset } -// TerminalWidth returns the terminal width. +// TerminalWidth returns the terminal window width. func (m Model) TerminalWidth() int { - return m.terminalManager.Width() + return m.width } -// TerminalHeight returns the terminal height. +// TerminalHeight returns the terminal window height. func (m Model) TerminalHeight() int { - return m.terminalManager.Height() + return m.height } // IsAddingTask returns whether the user is currently adding a new task diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 3344e4c..d1dacfe 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1142,7 +1142,6 @@ func TestCalculateSidebarMaxScrollOffset(t *testing.T) { } } - // Create model using the constructor to properly initialize terminal manager m := NewModel(nil, session, nil) m.sidebarMode = tt.sidebarMode m.groupViewState = tt.groupViewState diff --git a/internal/tui/panel/help.go b/internal/tui/panel/help.go index 84c67ed..a3dd02f 100644 --- a/internal/tui/panel/help.go +++ b/internal/tui/panel/help.go @@ -231,15 +231,6 @@ func DefaultHelpSections() []HelpSection { {Key: ":pr --group=single", Description: "Create PR for current group only"}, }, }, - { - Title: "Terminal Pane", - Items: []HelpItem{ - {Key: "` :term", Description: "Toggle terminal pane"}, - {Key: ":t", Description: "Focus terminal for typing"}, - {Key: "Ctrl+]", Description: "Exit terminal mode"}, - {Key: "Ctrl+Shift+T", Description: "Switch terminal directory"}, - }, - }, { Title: "Input Mode", Items: []HelpItem{ diff --git a/internal/tui/panel/help_test.go b/internal/tui/panel/help_test.go index 68a6a09..7623f77 100644 --- a/internal/tui/panel/help_test.go +++ b/internal/tui/panel/help_test.go @@ -28,7 +28,6 @@ func TestHelpPanel_Render(t *testing.T) { "Instance Management", "Adversarial Mode", "View Commands", - "Terminal Pane", "Input Mode", "Session", }, @@ -192,7 +191,6 @@ func TestDefaultHelpSections(t *testing.T) { "Planning Modes (experimental)", "Group Management", "View Commands", - "Terminal Pane", "Input Mode", "Session", } diff --git a/internal/tui/planeditor.go b/internal/tui/planeditor.go index b340ec0..d377a50 100644 --- a/internal/tui/planeditor.go +++ b/internal/tui/planeditor.go @@ -45,7 +45,7 @@ func (m Model) renderPlanEditorView(width int) string { Plan: plan, State: m.buildPlanEditorViewState(), Width: width, - Height: m.terminalManager.Height(), + Height: m.height, SelectedTaskValidationMessages: m.getValidationMessagesForSelectedTask(), }) } @@ -53,7 +53,7 @@ func (m Model) renderPlanEditorView(width int) string { // renderPlanEditorHelp renders the help bar for plan editor mode. // Delegates to the view package for the actual rendering. func (m Model) renderPlanEditorHelp() string { - return planEditorView.RenderHelp(m.buildPlanEditorViewState(), m.terminalManager.Width()) + return planEditorView.RenderHelp(m.buildPlanEditorViewState(), m.width) } // handlePlanEditorKeypress handles keyboard input for the plan editor mode. @@ -414,7 +414,7 @@ func (m *Model) planEditorMoveSelection(delta int, plan *orchestrator.PlanSpec) // planEditorEnsureVisible adjusts scroll offset to keep selected task visible func (m *Model) planEditorEnsureVisible(plan *orchestrator.PlanSpec) { // Calculate visible area (assume ~5 lines per task in compact view) - maxVisible := max(3, (m.terminalManager.Height()-10)/5) + maxVisible := max(3, (m.height-10)/5) if m.planEditor.selectedTaskIdx < m.planEditor.scrollOffset { m.planEditor.scrollOffset = m.planEditor.selectedTaskIdx diff --git a/internal/tui/planeditor_test.go b/internal/tui/planeditor_test.go index aef9670..9e10822 100644 --- a/internal/tui/planeditor_test.go +++ b/internal/tui/planeditor_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/Iron-Ham/claudio/internal/orchestrator" - "github.com/Iron-Ham/claudio/internal/tui/terminal" tea "github.com/charmbracelet/bubbletea" ) @@ -172,13 +171,13 @@ func TestPlanEditorMoveSelection(t *testing.T) { } m := Model{ - terminalManager: terminal.NewManager(), + width: 80, + height: 50, planEditor: &PlanEditorState{ active: true, selectedTaskIdx: tt.initialIdx, }, } - m.terminalManager.SetSize(80, 50) // Set height for scroll calculations m.planEditorMoveSelection(tt.delta, plan) @@ -226,14 +225,14 @@ func TestPlanEditorEnsureVisible(t *testing.T) { } m := Model{ - terminalManager: terminal.NewManager(), + width: 80, + height: tt.height, planEditor: &PlanEditorState{ active: true, selectedTaskIdx: tt.selectedIdx, scrollOffset: tt.initialScroll, }, } - m.terminalManager.SetSize(80, tt.height) m.planEditorEnsureVisible(plan) @@ -1536,9 +1535,8 @@ func TestEnterInlinePlanEditor(t *testing.T) { state.AddSession("test-group", session) m := Model{ - inlinePlan: state, - terminalManager: terminal.NewManager(), - inputMode: true, // Start in input mode to verify it gets disabled + inlinePlan: state, + inputMode: true, // Start in input mode to verify it gets disabled } m.enterInlinePlanEditor() @@ -1689,8 +1687,7 @@ func TestRenderPlanEditorView_InlineMode(t *testing.T) { active: true, inlineMode: true, }, - inlinePlan: state, - terminalManager: terminal.NewManager(), + inlinePlan: state, } // Should not panic and return something @@ -1706,8 +1703,7 @@ func TestRenderPlanEditorView_NoPlan(t *testing.T) { active: true, inlineMode: true, }, - inlinePlan: nil, - terminalManager: terminal.NewManager(), + inlinePlan: nil, } result := m.renderPlanEditorView(80) diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index b63dc87..9527d22 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -20,7 +20,6 @@ const ( // HeaderFooterReserved is the total vertical space reserved for // header and footer chrome in the main TUI view. - // This is used by terminal.Manager.GetPaneDimensions(). HeaderFooterReserved = HeaderLines + HelpBarLines + ViewNewlines // 8 ) @@ -125,12 +124,6 @@ var ( Background(WarningColor). Padding(0, 1) - ModeBadgeTerminal = lipgloss.NewStyle(). - Bold(true). - Foreground(TextColor). - Background(SecondaryColor). - Padding(0, 1) - ModeBadgeCommand = lipgloss.NewStyle(). Bold(true). Foreground(TextColor). @@ -316,25 +309,6 @@ var ( FilterCheckboxEmpty = lipgloss.NewStyle(). Foreground(MutedColor) - - // Terminal pane styles - TerminalPaneBorder = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(BorderColor). - Padding(0, 1) - - TerminalPaneBorderFocused = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(SecondaryColor). - Padding(0, 1) - - TerminalHeader = lipgloss.NewStyle(). - Foreground(MutedColor) - - TerminalFocusIndicator = lipgloss.NewStyle(). - Background(SecondaryColor). - Foreground(TextColor). - Bold(true) ) // StatusColor returns the color for a given status diff --git a/internal/tui/styles/themed.go b/internal/tui/styles/themed.go index 9da1b40..dfc9558 100644 --- a/internal/tui/styles/themed.go +++ b/internal/tui/styles/themed.go @@ -65,13 +65,12 @@ type ThemedStyles struct { HelpKey lipgloss.Style // Mode badges - ModeBadgeNormal lipgloss.Style - ModeBadgeInput lipgloss.Style - ModeBadgeTerminal lipgloss.Style - ModeBadgeCommand lipgloss.Style - ModeBadgeSearch lipgloss.Style - ModeBadgeFilter lipgloss.Style - ModeBadgeDiff lipgloss.Style + ModeBadgeNormal lipgloss.Style + ModeBadgeInput lipgloss.Style + ModeBadgeCommand lipgloss.Style + ModeBadgeSearch lipgloss.Style + ModeBadgeFilter lipgloss.Style + ModeBadgeDiff lipgloss.Style // Output area OutputArea lipgloss.Style @@ -128,12 +127,6 @@ type ThemedStyles struct { FilterCheckbox lipgloss.Style FilterCheckboxEmpty lipgloss.Style - // Terminal pane styles - TerminalPaneBorder lipgloss.Style - TerminalPaneBorderFocused lipgloss.Style - TerminalHeader lipgloss.Style - TerminalFocusIndicator lipgloss.Style - // Session type colors SessionTypePlanColor lipgloss.Color SessionTypeUltraPlanColor lipgloss.Color @@ -242,12 +235,6 @@ func NewThemedStyles(p *ColorPalette) *ThemedStyles { Background(p.Warning). Padding(0, 1) - s.ModeBadgeTerminal = lipgloss.NewStyle(). - Bold(true). - Foreground(p.Text). - Background(p.Secondary). - Padding(0, 1) - s.ModeBadgeCommand = lipgloss.NewStyle(). Bold(true). Foreground(p.Text). @@ -422,24 +409,6 @@ func NewThemedStyles(p *ColorPalette) *ThemedStyles { s.FilterCheckboxEmpty = lipgloss.NewStyle(). Foreground(p.Muted) - s.TerminalPaneBorder = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(p.Border). - Padding(0, 1) - - s.TerminalPaneBorderFocused = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(p.Secondary). - Padding(0, 1) - - s.TerminalHeader = lipgloss.NewStyle(). - Foreground(p.Muted) - - s.TerminalFocusIndicator = lipgloss.NewStyle(). - Background(p.Secondary). - Foreground(p.Text). - Bold(true) - return s } @@ -585,7 +554,6 @@ func syncGlobalStyles() { // Update mode badges ModeBadgeNormal = activeTheme.ModeBadgeNormal ModeBadgeInput = activeTheme.ModeBadgeInput - ModeBadgeTerminal = activeTheme.ModeBadgeTerminal ModeBadgeCommand = activeTheme.ModeBadgeCommand ModeBadgeSearch = activeTheme.ModeBadgeSearch ModeBadgeFilter = activeTheme.ModeBadgeFilter @@ -645,10 +613,4 @@ func syncGlobalStyles() { FilterCategoryDisabled = activeTheme.FilterCategoryDisabled FilterCheckbox = activeTheme.FilterCheckbox FilterCheckboxEmpty = activeTheme.FilterCheckboxEmpty - - // Update terminal pane styles - TerminalPaneBorder = activeTheme.TerminalPaneBorder - TerminalPaneBorderFocused = activeTheme.TerminalPaneBorderFocused - TerminalHeader = activeTheme.TerminalHeader - TerminalFocusIndicator = activeTheme.TerminalFocusIndicator } diff --git a/internal/tui/styles/themed_test.go b/internal/tui/styles/themed_test.go index 8e6e879..5c751bc 100644 --- a/internal/tui/styles/themed_test.go +++ b/internal/tui/styles/themed_test.go @@ -81,33 +81,31 @@ func TestThemedStyles_StylesCanRender(t *testing.T) { // Test that all styles can render without panicking styles := map[string]func() string{ - "Primary": func() string { return s.Primary.Render("test") }, - "Secondary": func() string { return s.Secondary.Render("test") }, - "Warning": func() string { return s.Warning.Render("test") }, - "Error": func() string { return s.Error.Render("test") }, - "Muted": func() string { return s.Muted.Render("test") }, - "Surface": func() string { return s.Surface.Render("test") }, - "Text": func() string { return s.Text.Render("test") }, - "Title": func() string { return s.Title.Render("test") }, - "Header": func() string { return s.Header.Render("test") }, - "HelpBar": func() string { return s.HelpBar.Render("test") }, - "HelpKey": func() string { return s.HelpKey.Render("test") }, - "Sidebar": func() string { return s.Sidebar.Render("test") }, - "SidebarItem": func() string { return s.SidebarItem.Render("test") }, - "SidebarItemActive": func() string { return s.SidebarItemActive.Render("test") }, - "DiffAdd": func() string { return s.DiffAdd.Render("test") }, - "DiffRemove": func() string { return s.DiffRemove.Render("test") }, - "DiffHeader": func() string { return s.DiffHeader.Render("test") }, - "DiffHunk": func() string { return s.DiffHunk.Render("test") }, - "DiffContext": func() string { return s.DiffContext.Render("test") }, - "SearchBar": func() string { return s.SearchBar.Render("test") }, - "SearchMatch": func() string { return s.SearchMatch.Render("test") }, - "DropdownItem": func() string { return s.DropdownItem.Render("test") }, - "DropdownItemSelected": func() string { return s.DropdownItemSelected.Render("test") }, - "ModeBadgeNormal": func() string { return s.ModeBadgeNormal.Render("test") }, - "ModeBadgeInput": func() string { return s.ModeBadgeInput.Render("test") }, - "TerminalPaneBorder": func() string { return s.TerminalPaneBorder.Render("test") }, - "TerminalPaneBorderFocused": func() string { return s.TerminalPaneBorderFocused.Render("test") }, + "Primary": func() string { return s.Primary.Render("test") }, + "Secondary": func() string { return s.Secondary.Render("test") }, + "Warning": func() string { return s.Warning.Render("test") }, + "Error": func() string { return s.Error.Render("test") }, + "Muted": func() string { return s.Muted.Render("test") }, + "Surface": func() string { return s.Surface.Render("test") }, + "Text": func() string { return s.Text.Render("test") }, + "Title": func() string { return s.Title.Render("test") }, + "Header": func() string { return s.Header.Render("test") }, + "HelpBar": func() string { return s.HelpBar.Render("test") }, + "HelpKey": func() string { return s.HelpKey.Render("test") }, + "Sidebar": func() string { return s.Sidebar.Render("test") }, + "SidebarItem": func() string { return s.SidebarItem.Render("test") }, + "SidebarItemActive": func() string { return s.SidebarItemActive.Render("test") }, + "DiffAdd": func() string { return s.DiffAdd.Render("test") }, + "DiffRemove": func() string { return s.DiffRemove.Render("test") }, + "DiffHeader": func() string { return s.DiffHeader.Render("test") }, + "DiffHunk": func() string { return s.DiffHunk.Render("test") }, + "DiffContext": func() string { return s.DiffContext.Render("test") }, + "SearchBar": func() string { return s.SearchBar.Render("test") }, + "SearchMatch": func() string { return s.SearchMatch.Render("test") }, + "DropdownItem": func() string { return s.DropdownItem.Render("test") }, + "DropdownItemSelected": func() string { return s.DropdownItemSelected.Render("test") }, + "ModeBadgeNormal": func() string { return s.ModeBadgeNormal.Render("test") }, + "ModeBadgeInput": func() string { return s.ModeBadgeInput.Render("test") }, } for name, renderFunc := range styles { diff --git a/internal/tui/terminal/manager.go b/internal/tui/terminal/manager.go deleted file mode 100644 index 2c1fbd6..0000000 --- a/internal/tui/terminal/manager.go +++ /dev/null @@ -1,732 +0,0 @@ -// Package terminal provides terminal pane management for the TUI. -package terminal - -import ( - "errors" - "log" - "strings" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/Iron-Ham/claudio/internal/logging" - "github.com/Iron-Ham/claudio/internal/tmux" - "github.com/Iron-Ham/claudio/internal/tui/styles" -) - -// DirMode indicates which directory the terminal pane is using. -type DirMode int - -const ( - // DirInvocation means the terminal is in the directory where Claudio was invoked. - DirInvocation DirMode = iota - // DirWorktree means the terminal is in the active instance's worktree directory. - DirWorktree -) - -// LayoutMode represents the terminal pane's visibility state. -type LayoutMode int - -const ( - // LayoutHidden means the terminal pane is not visible. - LayoutHidden LayoutMode = iota - // LayoutVisible means the terminal pane is visible at the bottom of the screen. - LayoutVisible -) - -// Layout constants for pane dimension calculations. -const ( - // DefaultPaneHeight is the default height of the terminal pane in lines. - DefaultPaneHeight = 15 - - // MinPaneHeight is the minimum height of the terminal pane. - MinPaneHeight = 5 - - // MaxPaneHeightRatio is the maximum ratio of terminal height to total height. - MaxPaneHeightRatio = 0.5 - - // TerminalPaneSpacing is the vertical spacing between main content and terminal pane. - TerminalPaneSpacing = 1 -) - -// PaneDimensions contains the calculated dimensions for all UI panes. -type PaneDimensions struct { - // TerminalWidth is the full width of the terminal window. - TerminalWidth int - // TerminalHeight is the full height of the terminal window. - TerminalHeight int - - // MainAreaHeight is the height available for the main content area - // (sidebar + content), accounting for header, footer, and terminal pane. - MainAreaHeight int - - // TerminalPaneHeight is the height of the terminal pane (0 if hidden). - TerminalPaneHeight int - - // TerminalPaneContentHeight is the usable content height inside the terminal pane - // (accounting for borders and header). - TerminalPaneContentHeight int - - // TerminalPaneContentWidth is the usable content width inside the terminal pane - // (accounting for borders and padding). - TerminalPaneContentWidth int -} - -// ActiveInstanceProvider returns the current active instance's worktree path. -// This interface decouples the terminal manager from the orchestrator. -type ActiveInstanceProvider interface { - // WorktreePath returns the worktree path of the active instance, or empty string if none. - WorktreePath() string -} - -// Manager tracks terminal dimensions, manages the terminal process, and calculates pane layouts. -// It centralizes all terminal pane concerns including process lifecycle, directory mode, -// output capture, and key forwarding. -type Manager struct { - // Terminal window dimensions - width int - height int - - // Terminal pane state - paneHeight int // Height of the terminal pane in lines - layout LayoutMode // Current layout mode (hidden/visible) - focused bool // Whether the terminal pane has input focus - - // Terminal process management - process *Process // Manages the terminal tmux session (nil until first toggle) - invocationDir string // Directory where Claudio was invoked (for terminal) - dirMode DirMode // Which directory the terminal is in - output string // Cached terminal output - - // Logger for error reporting - logger *logging.Logger -} - -// NewManager creates a new TerminalManager with default settings. -func NewManager() *Manager { - return &Manager{ - paneHeight: DefaultPaneHeight, - layout: LayoutHidden, - focused: false, - dirMode: DirInvocation, - } -} - -// ManagerConfig contains configuration options for creating a Manager. -type ManagerConfig struct { - InvocationDir string - Logger *logging.Logger -} - -// NewManagerWithConfig creates a new Manager with the given configuration. -func NewManagerWithConfig(cfg ManagerConfig) *Manager { - return &Manager{ - paneHeight: DefaultPaneHeight, - layout: LayoutHidden, - focused: false, - dirMode: DirInvocation, - invocationDir: cfg.InvocationDir, - logger: cfg.Logger, - } -} - -// SetInvocationDir sets the invocation directory for the terminal. -// This should be called before the first Toggle if not using NewManagerWithConfig. -func (m *Manager) SetInvocationDir(dir string) { - m.invocationDir = dir -} - -// SetLogger sets the logger for the terminal manager. -func (m *Manager) SetLogger(logger *logging.Logger) { - m.logger = logger -} - -// SetSize updates the terminal window dimensions. -// This should be called when the terminal is resized. -func (m *Manager) SetSize(width, height int) { - m.width = width - m.height = height -} - -// Width returns the current terminal width. -func (m *Manager) Width() int { - return m.width -} - -// Height returns the current terminal height. -func (m *Manager) Height() int { - return m.height -} - -// GetPaneDimensions calculates and returns the dimensions for all UI panes -// based on the current terminal size and layout mode. The extraFooterLines -// parameter specifies additional lines to reserve for dynamic footer elements -// such as error messages, info messages, and conflict warnings. -func (m *Manager) GetPaneDimensions(extraFooterLines int) PaneDimensions { - dims := PaneDimensions{ - TerminalWidth: m.width, - TerminalHeight: m.height, - } - - // Calculate terminal pane height (0 if hidden) - if m.layout == LayoutVisible { - dims.TerminalPaneHeight = m.effectivePaneHeight() - - // Content dimensions account for border (2 lines: top/bottom) and header (1 line) - dims.TerminalPaneContentHeight = max(dims.TerminalPaneHeight-3, 3) - - // Width accounts for border (2 chars) and padding (2 chars) - dims.TerminalPaneContentWidth = max(m.width-4, 20) - } - - // Calculate main area height - // Use centralized constant from styles package to stay in sync with style definitions. - // Plus any extra footer lines for dynamic elements (error messages, conflict warnings) - dims.MainAreaHeight = m.height - styles.HeaderFooterReserved - max(extraFooterLines, 0) - - // Reduce main area when terminal pane is visible - if m.layout == LayoutVisible && dims.TerminalPaneHeight > 0 { - dims.MainAreaHeight -= dims.TerminalPaneHeight + TerminalPaneSpacing - } - - // Enforce minimum main area height - const minMainAreaHeight = 10 - if dims.MainAreaHeight < minMainAreaHeight { - dims.MainAreaHeight = minMainAreaHeight - } - - return dims -} - -// ToggleFocus toggles input focus between the terminal pane and main content. -// Returns true if the terminal pane now has focus, false otherwise. -func (m *Manager) ToggleFocus() bool { - // Can only focus terminal pane if it's visible - if m.layout != LayoutVisible { - m.focused = false - return false - } - m.focused = !m.focused - return m.focused -} - -// SetFocused explicitly sets the focus state of the terminal pane. -func (m *Manager) SetFocused(focused bool) { - // Can only focus if visible - if m.layout != LayoutVisible { - m.focused = false - return - } - m.focused = focused -} - -// IsFocused returns true if the terminal pane has input focus. -func (m *Manager) IsFocused() bool { - return m.focused && m.layout == LayoutVisible -} - -// SetLayout sets the terminal pane layout mode. -func (m *Manager) SetLayout(layout LayoutMode) { - m.layout = layout - // Clear focus when hiding terminal pane - if layout == LayoutHidden { - m.focused = false - } -} - -// Layout returns the current layout mode. -func (m *Manager) Layout() LayoutMode { - return m.layout -} - -// IsVisible returns true if the terminal pane is visible. -func (m *Manager) IsVisible() bool { - return m.layout == LayoutVisible -} - -// ToggleVisibility toggles the terminal pane between visible and hidden. -// Returns true if the terminal pane is now visible, false otherwise. -func (m *Manager) ToggleVisibility() bool { - if m.layout == LayoutVisible { - m.layout = LayoutHidden - m.focused = false - } else { - m.layout = LayoutVisible - } - return m.layout == LayoutVisible -} - -// SetPaneHeight sets the terminal pane height. -// The height is clamped to valid bounds based on the current terminal size. -func (m *Manager) SetPaneHeight(height int) { - m.paneHeight = height -} - -// PaneHeight returns the configured terminal pane height. -// Note: Use GetPaneDimensions().TerminalPaneHeight for the effective height -// which accounts for visibility and clamping. -func (m *Manager) PaneHeight() int { - return m.paneHeight -} - -// effectivePaneHeight returns the actual pane height to use, -// applying defaults and clamping to valid bounds. -func (m *Manager) effectivePaneHeight() int { - height := m.paneHeight - if height == 0 { - height = DefaultPaneHeight - } - - // Clamp to minimum - height = max(height, MinPaneHeight) - - // Clamp to maximum (based on terminal height) - maxHeight := max(int(float64(m.height)*MaxPaneHeightRatio), MinPaneHeight) - height = min(height, maxHeight) - - return height -} - -// ResizePaneHeight adjusts the terminal pane height by delta lines. -// Positive delta increases height, negative decreases it. -func (m *Manager) ResizePaneHeight(delta int) { - m.paneHeight = max(m.paneHeight+delta, MinPaneHeight) -} - -// ----------------------------------------------------------------------------- -// Process management methods -// These methods manage the terminal's tmux process lifecycle. -// ----------------------------------------------------------------------------- - -// Toggle toggles the terminal pane visibility and manages the process lifecycle. -// If turning on and the process doesn't exist, it will be created lazily. -// Returns an error message for fatal errors, and a warning message for non-fatal issues. -// The sessionID is used to create a unique tmux session name. -func (m *Manager) Toggle(sessionID string) (errMsg, warnMsg string) { - nowVisible := m.ToggleVisibility() - - if nowVisible { - // Initialize terminal process if needed (lazy initialization) - if m.process == nil { - dims := m.GetPaneDimensions(0) - m.process = NewProcess(sessionID, m.invocationDir, dims.TerminalPaneContentWidth, dims.TerminalPaneContentHeight) - } - - // Start the process if not running - if !m.process.IsRunning() { - if err := m.process.Start(); err != nil { - m.SetLayout(LayoutHidden) - return "Failed to start terminal: " + err.Error(), "" - } - } - - // Set initial directory based on mode - targetDir := m.GetDir(nil) - if m.process.CurrentDir() != targetDir { - if err := m.process.ChangeDirectory(targetDir); err != nil { - if m.logger != nil { - m.logger.Warn("failed to set initial terminal directory", "target", targetDir, "error", err) - } - // Terminal is open and functional, just in wrong directory - return as warning - return "", "Terminal opened but could not change to target directory" - } - } - } - - return "", "" -} - -// EnterMode enters terminal input mode (keys go to terminal). -// This is a no-op if the terminal is not visible or no process is running. -func (m *Manager) EnterMode() { - if !m.IsVisible() || m.process == nil || !m.process.IsRunning() { - return - } - m.SetFocused(true) -} - -// ExitMode exits terminal input mode. -func (m *Manager) ExitMode() { - m.SetFocused(false) -} - -// GetDir returns the directory path for the terminal based on current mode. -// If the mode is DirWorktree and a provider is given, it returns the active instance's worktree path. -// Falls back to invocation directory if no worktree is available. -func (m *Manager) GetDir(provider ActiveInstanceProvider) string { - if m.dirMode == DirWorktree && provider != nil { - if path := provider.WorktreePath(); path != "" { - return path - } - } - return m.invocationDir -} - -// SwitchDir toggles between worktree and invocation directory modes. -// Returns an info message describing the result, or an error message if the directory change failed. -func (m *Manager) SwitchDir(provider ActiveInstanceProvider) (infoMsg, errMsg string) { - if m.dirMode == DirInvocation { - m.dirMode = DirWorktree - } else { - m.dirMode = DirInvocation - } - - // Change directory if terminal is running - if m.process != nil && m.process.IsRunning() { - targetDir := m.GetDir(provider) - if err := m.process.ChangeDirectory(targetDir); err != nil { - return "", "Failed to change directory: " + err.Error() - } - if m.dirMode == DirWorktree { - return "Terminal: switched to worktree", "" - } - return "Terminal: switched to invocation directory", "" - } - - // Provide feedback even when terminal is not running - if m.dirMode == DirWorktree { - return "Terminal will use worktree when opened", "" - } - return "Terminal will use invocation directory when opened", "" -} - -// DirMode returns the current directory mode. -func (m *Manager) DirMode() DirMode { - return m.dirMode -} - -// SetDirMode sets the directory mode. -func (m *Manager) SetDirMode(mode DirMode) { - m.dirMode = mode -} - -// UpdateOutput captures current terminal output. -// If the capture times out (e.g., tmux is unresponsive), the previous output is preserved. -func (m *Manager) UpdateOutput() { - if m.process == nil || !m.process.IsRunning() { - return - } - - output, err := m.process.CaptureOutput() - if err != nil { - // Log at debug level for expected interruptions (timeout, cancelled, killed) - // since they don't require user attention. Other errors are logged as warnings. - if errors.Is(err, ErrCaptureTimeout) || errors.Is(err, ErrCaptureCancelled) || errors.Is(err, ErrCaptureKilled) { - if m.logger != nil { - m.logger.Debug("terminal output capture interrupted", "error", err) - } - } else { - // Unexpected errors should always be logged, even without a configured logger - if m.logger != nil { - m.logger.Warn("failed to capture terminal output", "error", err) - } else { - log.Printf("WARNING: failed to capture terminal output: %v", err) - } - } - // On error, preserve the previous output so the display doesn't go blank - return - } - m.output = output -} - -// Output returns the cached terminal output. -func (m *Manager) Output() string { - return m.output -} - -// Resize updates the terminal dimensions. -func (m *Manager) Resize() { - if m.process == nil { - return - } - - // Get content dimensions from manager (accounts for borders, padding, header) - dims := m.GetPaneDimensions(0) - - if err := m.process.Resize(dims.TerminalPaneContentWidth, dims.TerminalPaneContentHeight); err != nil { - if m.logger != nil { - m.logger.Warn("failed to resize terminal", "width", dims.TerminalPaneContentWidth, "height", dims.TerminalPaneContentHeight, "error", err) - } - } -} - -// Cleanup stops the terminal process (called on quit). -func (m *Manager) Cleanup() { - if m.process != nil { - if err := m.process.Stop(); err != nil { - if m.logger != nil { - m.logger.Warn("failed to cleanup terminal session", "error", err) - } - } - } -} - -// UpdateOnInstanceChange updates terminal directory if in worktree mode. -// Called when the active instance changes. -func (m *Manager) UpdateOnInstanceChange(provider ActiveInstanceProvider) string { - if m.dirMode != DirWorktree { - return "" - } - if m.process == nil || !m.process.IsRunning() { - return "" - } - - targetDir := m.GetDir(provider) - if m.process.CurrentDir() != targetDir { - if err := m.process.ChangeDirectory(targetDir); err != nil { - return "Failed to change terminal directory: " + err.Error() - } - } - return "" -} - -// Process returns the underlying terminal process, or nil if not initialized. -// This is provided for cases where direct process access is needed. -func (m *Manager) Process() *Process { - return m.process -} - -// ----------------------------------------------------------------------------- -// Key sending methods -// These methods forward key events to the terminal's tmux session. -// ----------------------------------------------------------------------------- - -// SendKey sends a key event to the terminal pane's tmux session. -// This translates tea.KeyMsg to tmux key names. -func (m *Manager) SendKey(msg tea.KeyMsg) { - if m.process == nil || !m.process.IsRunning() { - return - } - - // Helper to log terminal key send errors - logKeyErr := func(op, key string, err error) { - if err != nil && m.logger != nil { - m.logger.Warn("failed to send key to terminal", "op", op, "key", key, "error", err) - } - } - - var key string - literal := false - - switch msg.Type { - // Basic keys - case tea.KeyEnter: - key = "Enter" - case tea.KeyBackspace: - if msg.Alt { - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - logKeyErr("SendKey", "BSpace", m.process.SendKey("BSpace")) - return - } - key = "BSpace" - case tea.KeyTab: - key = "Tab" - case tea.KeyShiftTab: - key = "BTab" - case tea.KeySpace: - key = " " - literal = true - case tea.KeyEsc: - key = "Escape" - - // Arrow keys - check for Alt modifier (Opt+Arrow on macOS) - case tea.KeyUp: - if msg.Alt { - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - logKeyErr("SendKey", "Up", m.process.SendKey("Up")) - return - } - key = "Up" - case tea.KeyDown: - if msg.Alt { - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - logKeyErr("SendKey", "Down", m.process.SendKey("Down")) - return - } - key = "Down" - case tea.KeyRight: - if msg.Alt { - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - logKeyErr("SendKey", "Right", m.process.SendKey("Right")) - return - } - key = "Right" - case tea.KeyLeft: - if msg.Alt { - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - logKeyErr("SendKey", "Left", m.process.SendKey("Left")) - return - } - key = "Left" - - // Navigation keys - case tea.KeyPgUp: - key = "PageUp" - case tea.KeyPgDown: - key = "PageDown" - case tea.KeyHome: - key = "Home" - case tea.KeyEnd: - key = "End" - case tea.KeyDelete: - key = "DC" - case tea.KeyInsert: - key = "IC" - - // Ctrl+letter combinations - case tea.KeyCtrlA: - key = "C-a" - case tea.KeyCtrlB: - key = "C-b" - case tea.KeyCtrlC: - key = "C-c" - case tea.KeyCtrlD: - key = "C-d" - case tea.KeyCtrlE: - key = "C-e" - case tea.KeyCtrlF: - key = "C-f" - case tea.KeyCtrlG: - key = "C-g" - case tea.KeyCtrlH: - key = "C-h" - case tea.KeyCtrlJ: - key = "C-j" - case tea.KeyCtrlK: - key = "C-k" - case tea.KeyCtrlL: - key = "C-l" - case tea.KeyCtrlN: - key = "C-n" - case tea.KeyCtrlO: - key = "C-o" - case tea.KeyCtrlP: - key = "C-p" - case tea.KeyCtrlQ: - key = "C-q" - case tea.KeyCtrlR: - key = "C-r" - case tea.KeyCtrlS: - key = "C-s" - case tea.KeyCtrlT: - key = "C-t" - case tea.KeyCtrlU: - key = "C-u" - case tea.KeyCtrlV: - key = "C-v" - case tea.KeyCtrlW: - key = "C-w" - case tea.KeyCtrlX: - key = "C-x" - case tea.KeyCtrlY: - key = "C-y" - case tea.KeyCtrlZ: - key = "C-z" - - // Function keys - case tea.KeyF1: - key = "F1" - case tea.KeyF2: - key = "F2" - case tea.KeyF3: - key = "F3" - case tea.KeyF4: - key = "F4" - case tea.KeyF5: - key = "F5" - case tea.KeyF6: - key = "F6" - case tea.KeyF7: - key = "F7" - case tea.KeyF8: - key = "F8" - case tea.KeyF9: - key = "F9" - case tea.KeyF10: - key = "F10" - case tea.KeyF11: - key = "F11" - case tea.KeyF12: - key = "F12" - - // Runes (regular characters) - case tea.KeyRunes: - if len(msg.Runes) > 0 { - // Handle Alt+key combinations by sending Escape followed by the key - if msg.Alt { - key = string(msg.Runes) - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - logKeyErr("SendLiteral", key, m.process.SendLiteral(key)) - return - } - key = string(msg.Runes) - literal = true - } - - default: - // Handle complex key strings (shift+, alt+, ctrl+ combinations) - keyStr := msg.String() - switch { - case strings.HasPrefix(keyStr, "shift+"): - baseKey := strings.TrimPrefix(keyStr, "shift+") - switch baseKey { - case "up": - key = "S-Up" - case "down": - key = "S-Down" - case "left": - key = "S-Left" - case "right": - key = "S-Right" - case "home": - key = "S-Home" - case "end": - key = "S-End" - default: - key = keyStr - } - case strings.HasPrefix(keyStr, "alt+"): - baseKey := strings.TrimPrefix(keyStr, "alt+") - logKeyErr("SendKey", "Escape", m.process.SendKey("Escape")) - if len(baseKey) == 1 { - logKeyErr("SendLiteral", baseKey, m.process.SendLiteral(baseKey)) - } else { - // Map Bubble Tea key names to tmux key names - tmuxKey := tmux.MapKeyToTmux(baseKey) - logKeyErr("SendKey", tmuxKey, m.process.SendKey(tmuxKey)) - } - return - case strings.HasPrefix(keyStr, "ctrl+"): - baseKey := strings.TrimPrefix(keyStr, "ctrl+") - if len(baseKey) == 1 { - key = "C-" + baseKey - } else { - key = keyStr - } - default: - key = keyStr - if len(key) == 1 { - literal = true - } - } - } - - if key == "" { - return - } - - var err error - if literal { - err = m.process.SendLiteral(key) - } else { - err = m.process.SendKey(key) - } - logKeyErr("send", key, err) -} - -// SendPaste sends pasted text with bracketed paste sequences. -func (m *Manager) SendPaste(text string) error { - if m.process == nil || !m.process.IsRunning() { - return ErrNotRunning - } - return m.process.SendPaste(text) -} diff --git a/internal/tui/terminal/manager_test.go b/internal/tui/terminal/manager_test.go deleted file mode 100644 index 6bacc85..0000000 --- a/internal/tui/terminal/manager_test.go +++ /dev/null @@ -1,900 +0,0 @@ -package terminal - -import ( - "slices" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/Iron-Ham/claudio/internal/tui/styles" -) - -func TestNewManager(t *testing.T) { - m := NewManager() - - if m.paneHeight != DefaultPaneHeight { - t.Errorf("NewManager().paneHeight = %d, want %d", m.paneHeight, DefaultPaneHeight) - } - if m.layout != LayoutHidden { - t.Errorf("NewManager().layout = %v, want LayoutHidden", m.layout) - } - if m.focused { - t.Error("NewManager().focused = true, want false") - } -} - -func TestSetSize(t *testing.T) { - m := NewManager() - m.SetSize(120, 40) - - if m.Width() != 120 { - t.Errorf("Width() = %d, want 120", m.Width()) - } - if m.Height() != 40 { - t.Errorf("Height() = %d, want 40", m.Height()) - } -} - -func TestGetPaneDimensions_Hidden(t *testing.T) { - m := NewManager() - m.SetSize(120, 40) - m.SetLayout(LayoutHidden) - - dims := m.GetPaneDimensions(0) - - if dims.TerminalPaneHeight != 0 { - t.Errorf("TerminalPaneHeight = %d, want 0 when hidden", dims.TerminalPaneHeight) - } - if dims.TerminalPaneContentHeight != 0 { - t.Errorf("TerminalPaneContentHeight = %d, want 0 when hidden", dims.TerminalPaneContentHeight) - } - if dims.TerminalPaneContentWidth != 0 { - t.Errorf("TerminalPaneContentWidth = %d, want 0 when hidden", dims.TerminalPaneContentWidth) - } - - // Main area should be full height minus reserved space - expectedMainArea := 40 - styles.HeaderFooterReserved - if dims.MainAreaHeight != expectedMainArea { - t.Errorf("MainAreaHeight = %d, want %d", dims.MainAreaHeight, expectedMainArea) - } -} - -func TestGetPaneDimensions_Visible(t *testing.T) { - m := NewManager() - m.SetSize(120, 40) - m.SetLayout(LayoutVisible) - - dims := m.GetPaneDimensions(0) - - // Terminal pane should have default height - if dims.TerminalPaneHeight != DefaultPaneHeight { - t.Errorf("TerminalPaneHeight = %d, want %d", dims.TerminalPaneHeight, DefaultPaneHeight) - } - - // Content height = pane height - 3 (borders + header) - expectedContentHeight := DefaultPaneHeight - 3 - if dims.TerminalPaneContentHeight != expectedContentHeight { - t.Errorf("TerminalPaneContentHeight = %d, want %d", dims.TerminalPaneContentHeight, expectedContentHeight) - } - - // Content width = terminal width - 4 (borders + padding) - expectedContentWidth := 120 - 4 - if dims.TerminalPaneContentWidth != expectedContentWidth { - t.Errorf("TerminalPaneContentWidth = %d, want %d", dims.TerminalPaneContentWidth, expectedContentWidth) - } - - // Main area should be reduced by terminal pane height + spacing - expectedMainArea := 40 - styles.HeaderFooterReserved - DefaultPaneHeight - TerminalPaneSpacing - if dims.MainAreaHeight != expectedMainArea { - t.Errorf("MainAreaHeight = %d, want %d", dims.MainAreaHeight, expectedMainArea) - } -} - -func TestGetPaneDimensions_MinMainAreaHeight(t *testing.T) { - m := NewManager() - // Very short terminal where main area would be negative without minimum - m.SetSize(80, 20) - m.SetLayout(LayoutVisible) - m.SetPaneHeight(30) // Try to set huge pane height - - dims := m.GetPaneDimensions(0) - - // Main area should be at least 10 - if dims.MainAreaHeight < 10 { - t.Errorf("MainAreaHeight = %d, want >= 10", dims.MainAreaHeight) - } -} - -func TestGetPaneDimensions_MinContentDimensions(t *testing.T) { - m := NewManager() - // Very small terminal - m.SetSize(15, 15) - m.SetLayout(LayoutVisible) - m.SetPaneHeight(MinPaneHeight) - - dims := m.GetPaneDimensions(0) - - // Content height should be at least 3 - if dims.TerminalPaneContentHeight < 3 { - t.Errorf("TerminalPaneContentHeight = %d, want >= 3", dims.TerminalPaneContentHeight) - } - - // Content width should be at least 20 - if dims.TerminalPaneContentWidth < 20 { - t.Errorf("TerminalPaneContentWidth = %d, want >= 20", dims.TerminalPaneContentWidth) - } -} - -func TestToggleFocus(t *testing.T) { - tests := []struct { - name string - initialLayout LayoutMode - initialFocused bool - wantFocused bool - }{ - { - name: "toggle focus when visible and unfocused", - initialLayout: LayoutVisible, - initialFocused: false, - wantFocused: true, - }, - { - name: "toggle focus when visible and focused", - initialLayout: LayoutVisible, - initialFocused: true, - wantFocused: false, - }, - { - name: "cannot focus when hidden", - initialLayout: LayoutHidden, - initialFocused: false, - wantFocused: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManager() - m.SetLayout(tt.initialLayout) - if tt.initialFocused { - m.SetFocused(true) - } - - got := m.ToggleFocus() - if got != tt.wantFocused { - t.Errorf("ToggleFocus() = %v, want %v", got, tt.wantFocused) - } - if m.IsFocused() != tt.wantFocused { - t.Errorf("IsFocused() = %v, want %v", m.IsFocused(), tt.wantFocused) - } - }) - } -} - -func TestSetFocused(t *testing.T) { - tests := []struct { - name string - layout LayoutMode - setFocused bool - expectFocused bool - }{ - { - name: "set focused when visible", - layout: LayoutVisible, - setFocused: true, - expectFocused: true, - }, - { - name: "clear focus when visible", - layout: LayoutVisible, - setFocused: false, - expectFocused: false, - }, - { - name: "cannot focus when hidden", - layout: LayoutHidden, - setFocused: true, - expectFocused: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManager() - m.SetLayout(tt.layout) - m.SetFocused(tt.setFocused) - - if m.IsFocused() != tt.expectFocused { - t.Errorf("IsFocused() = %v, want %v", m.IsFocused(), tt.expectFocused) - } - }) - } -} - -func TestSetLayout(t *testing.T) { - m := NewManager() - - // Initially hidden - if m.Layout() != LayoutHidden { - t.Errorf("initial Layout() = %v, want LayoutHidden", m.Layout()) - } - if m.IsVisible() { - t.Error("initial IsVisible() = true, want false") - } - - // Set to visible - m.SetLayout(LayoutVisible) - if m.Layout() != LayoutVisible { - t.Errorf("Layout() = %v, want LayoutVisible", m.Layout()) - } - if !m.IsVisible() { - t.Error("IsVisible() = false, want true") - } - - // Focus then hide should clear focus - m.SetFocused(true) - if !m.IsFocused() { - t.Error("IsFocused() = false, want true after SetFocused(true)") - } - - m.SetLayout(LayoutHidden) - if m.IsFocused() { - t.Error("IsFocused() = true, want false after hiding") - } -} - -func TestToggleVisibility(t *testing.T) { - m := NewManager() - - // Initially hidden - if m.IsVisible() { - t.Error("initial IsVisible() = true, want false") - } - - // Toggle to visible - visible := m.ToggleVisibility() - if !visible { - t.Error("ToggleVisibility() = false, want true") - } - if !m.IsVisible() { - t.Error("IsVisible() = false, want true") - } - - // Set focus then toggle to hidden - should clear focus - m.SetFocused(true) - visible = m.ToggleVisibility() - if visible { - t.Error("ToggleVisibility() = true, want false") - } - if m.IsVisible() { - t.Error("IsVisible() = true, want false") - } - if m.IsFocused() { - t.Error("IsFocused() = true after toggle to hidden, want false") - } -} - -func TestSetPaneHeight(t *testing.T) { - m := NewManager() - - m.SetPaneHeight(20) - if m.PaneHeight() != 20 { - t.Errorf("PaneHeight() = %d, want 20", m.PaneHeight()) - } -} - -func TestEffectivePaneHeight(t *testing.T) { - tests := []struct { - name string - terminalHeight int - setPaneHeight int - expectedEffectv int - }{ - { - name: "default height when zero", - terminalHeight: 100, - setPaneHeight: 0, - expectedEffectv: DefaultPaneHeight, - }, - { - name: "custom height within bounds", - terminalHeight: 100, - setPaneHeight: 20, - expectedEffectv: 20, - }, - { - name: "clamp to minimum", - terminalHeight: 100, - setPaneHeight: 2, - expectedEffectv: MinPaneHeight, - }, - { - name: "clamp to maximum ratio", - terminalHeight: 40, - setPaneHeight: 30, - expectedEffectv: 20, // 40 * 0.5 = 20 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManager() - m.SetSize(80, tt.terminalHeight) - m.SetPaneHeight(tt.setPaneHeight) - m.SetLayout(LayoutVisible) - - dims := m.GetPaneDimensions(0) - if dims.TerminalPaneHeight != tt.expectedEffectv { - t.Errorf("TerminalPaneHeight = %d, want %d", dims.TerminalPaneHeight, tt.expectedEffectv) - } - }) - } -} - -func TestResizePaneHeight(t *testing.T) { - tests := []struct { - name string - initialHeight int - delta int - expectedHeight int - }{ - { - name: "increase height", - initialHeight: 15, - delta: 5, - expectedHeight: 20, - }, - { - name: "decrease height", - initialHeight: 20, - delta: -5, - expectedHeight: 15, - }, - { - name: "clamp to minimum when decreasing", - initialHeight: 10, - delta: -10, - expectedHeight: MinPaneHeight, - }, - { - name: "clamp to minimum with large negative delta", - initialHeight: 15, - delta: -100, - expectedHeight: MinPaneHeight, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManager() - m.SetPaneHeight(tt.initialHeight) - m.ResizePaneHeight(tt.delta) - - if m.PaneHeight() != tt.expectedHeight { - t.Errorf("PaneHeight() = %d, want %d", m.PaneHeight(), tt.expectedHeight) - } - }) - } -} - -func TestLayoutModeConstants(t *testing.T) { - // Ensure LayoutHidden is zero value (default) - if LayoutHidden != 0 { - t.Errorf("LayoutHidden = %d, want 0", LayoutHidden) - } - if LayoutVisible != 1 { - t.Errorf("LayoutVisible = %d, want 1", LayoutVisible) - } -} - -func TestPaneDimensions_TerminalDimensions(t *testing.T) { - m := NewManager() - m.SetSize(120, 40) - - dims := m.GetPaneDimensions(0) - - if dims.TerminalWidth != 120 { - t.Errorf("TerminalWidth = %d, want 120", dims.TerminalWidth) - } - if dims.TerminalHeight != 40 { - t.Errorf("TerminalHeight = %d, want 40", dims.TerminalHeight) - } -} - -func TestIsFocused_RequiresBothFocusAndVisible(t *testing.T) { - m := NewManager() - - // Not focused, not visible - if m.IsFocused() { - t.Error("IsFocused() = true when not focused and not visible") - } - - // Set visible but not focused - m.SetLayout(LayoutVisible) - if m.IsFocused() { - t.Error("IsFocused() = true when visible but not focused") - } - - // Set focused and visible - m.SetFocused(true) - if !m.IsFocused() { - t.Error("IsFocused() = false when focused and visible") - } - - // Hide but keep focused flag (should return false) - m.SetLayout(LayoutHidden) - // Note: SetLayout clears focused when hiding - if m.IsFocused() { - t.Error("IsFocused() = true when focused flag set but hidden") - } -} - -func TestGetPaneDimensions_WithExtraFooterLines(t *testing.T) { - tests := []struct { - name string - terminalHeight int - extraFooterLines int - }{ - { - name: "no extra lines", - terminalHeight: 40, - extraFooterLines: 0, - }, - { - name: "one extra line for error message", - terminalHeight: 40, - extraFooterLines: 1, - }, - { - name: "two extra lines for error and conflicts", - terminalHeight: 40, - extraFooterLines: 2, - }, - { - name: "three extra lines for verbose help", - terminalHeight: 40, - extraFooterLines: 3, - }, - { - name: "negative lines clamped to zero", - terminalHeight: 40, - extraFooterLines: -5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManager() - m.SetSize(80, tt.terminalHeight) - m.SetLayout(LayoutHidden) // Keep terminal pane hidden for simplicity - - dims := m.GetPaneDimensions(tt.extraFooterLines) - - // Compute expected value using the centralized constant - clampedExtra := max(tt.extraFooterLines, 0) - expectedMainArea := tt.terminalHeight - styles.HeaderFooterReserved - clampedExtra - - if dims.MainAreaHeight != expectedMainArea { - t.Errorf("MainAreaHeight = %d, want %d", dims.MainAreaHeight, expectedMainArea) - } - }) - } -} - -func TestGetPaneDimensions_ExtraFooterLinesWithTerminalPane(t *testing.T) { - m := NewManager() - m.SetSize(120, 50) - m.SetLayout(LayoutVisible) - - extraFooterLines := 2 // error message + conflict warning - dims := m.GetPaneDimensions(extraFooterLines) - - // Expected: height - headerFooterReserved - extraFooterLines - terminalPaneHeight - spacing - expectedMainArea := 50 - styles.HeaderFooterReserved - extraFooterLines - DefaultPaneHeight - TerminalPaneSpacing - if dims.MainAreaHeight != expectedMainArea { - t.Errorf("MainAreaHeight = %d, want %d", dims.MainAreaHeight, expectedMainArea) - } -} - -// ----------------------------------------------------------------------------- -// Tests for new Manager methods -// ----------------------------------------------------------------------------- - -func TestNewManagerWithConfig(t *testing.T) { - cfg := ManagerConfig{ - InvocationDir: "/home/user/project", - Logger: nil, - } - - m := NewManagerWithConfig(cfg) - - if m.invocationDir != cfg.InvocationDir { - t.Errorf("invocationDir = %q, want %q", m.invocationDir, cfg.InvocationDir) - } - if m.paneHeight != DefaultPaneHeight { - t.Errorf("paneHeight = %d, want %d", m.paneHeight, DefaultPaneHeight) - } - if m.layout != LayoutHidden { - t.Errorf("layout = %v, want LayoutHidden", m.layout) - } - if m.dirMode != DirInvocation { - t.Errorf("dirMode = %v, want DirInvocation", m.dirMode) - } -} - -func TestSetInvocationDir(t *testing.T) { - m := NewManager() - m.SetInvocationDir("/test/dir") - - if m.invocationDir != "/test/dir" { - t.Errorf("invocationDir = %q, want %q", m.invocationDir, "/test/dir") - } -} - -func TestDirMode(t *testing.T) { - m := NewManager() - - // Initially in invocation mode - if m.DirMode() != DirInvocation { - t.Errorf("initial DirMode() = %v, want DirInvocation", m.DirMode()) - } - - // Set to worktree mode - m.SetDirMode(DirWorktree) - if m.DirMode() != DirWorktree { - t.Errorf("DirMode() = %v, want DirWorktree", m.DirMode()) - } - - // Set back to invocation mode - m.SetDirMode(DirInvocation) - if m.DirMode() != DirInvocation { - t.Errorf("DirMode() = %v, want DirInvocation", m.DirMode()) - } -} - -// mockInstanceProvider implements ActiveInstanceProvider for testing -type mockInstanceProvider struct { - worktreePath string -} - -func (p mockInstanceProvider) WorktreePath() string { - return p.worktreePath -} - -func TestGetDir(t *testing.T) { - tests := []struct { - name string - dirMode DirMode - invocationDir string - worktreePath string - expectedDir string - }{ - { - name: "invocation mode returns invocation dir", - dirMode: DirInvocation, - invocationDir: "/home/user/project", - worktreePath: "/tmp/worktree", - expectedDir: "/home/user/project", - }, - { - name: "worktree mode returns worktree path", - dirMode: DirWorktree, - invocationDir: "/home/user/project", - worktreePath: "/tmp/worktree", - expectedDir: "/tmp/worktree", - }, - { - name: "worktree mode with empty path falls back to invocation", - dirMode: DirWorktree, - invocationDir: "/home/user/project", - worktreePath: "", - expectedDir: "/home/user/project", - }, - { - name: "worktree mode with nil provider falls back to invocation", - dirMode: DirWorktree, - invocationDir: "/home/user/project", - worktreePath: "", // nil provider scenario - expectedDir: "/home/user/project", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManagerWithConfig(ManagerConfig{ - InvocationDir: tt.invocationDir, - }) - m.SetDirMode(tt.dirMode) - - var provider ActiveInstanceProvider - if tt.worktreePath != "" { - provider = mockInstanceProvider{worktreePath: tt.worktreePath} - } - - got := m.GetDir(provider) - if got != tt.expectedDir { - t.Errorf("GetDir() = %q, want %q", got, tt.expectedDir) - } - }) - } -} - -func TestEnterMode(t *testing.T) { - m := NewManager() - - // Cannot enter mode when not visible - m.EnterMode() - if m.IsFocused() { - t.Error("EnterMode() should not focus when pane is hidden") - } - - // Make visible but no process - still cannot enter - m.SetLayout(LayoutVisible) - m.EnterMode() - if m.IsFocused() { - t.Error("EnterMode() should not focus when no process exists") - } -} - -func TestExitMode(t *testing.T) { - m := NewManager() - m.SetLayout(LayoutVisible) - m.SetFocused(true) - - if !m.IsFocused() { - t.Error("precondition: manager should be focused") - } - - m.ExitMode() - if m.IsFocused() { - t.Error("ExitMode() should clear focus") - } -} - -func TestSwitchDir(t *testing.T) { - tests := []struct { - name string - initialMode DirMode - expectedMode DirMode - expectedInfoMsg string - }{ - { - name: "switch from invocation to worktree without process", - initialMode: DirInvocation, - expectedMode: DirWorktree, - expectedInfoMsg: "Terminal will use worktree when opened", - }, - { - name: "switch from worktree to invocation without process", - initialMode: DirWorktree, - expectedMode: DirInvocation, - expectedInfoMsg: "Terminal will use invocation directory when opened", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewManager() - m.SetDirMode(tt.initialMode) - m.SetInvocationDir("/home/user/project") - - infoMsg, errMsg := m.SwitchDir(nil) - - if m.DirMode() != tt.expectedMode { - t.Errorf("DirMode() = %v, want %v", m.DirMode(), tt.expectedMode) - } - if errMsg != "" { - t.Errorf("SwitchDir() errMsg = %q, want empty", errMsg) - } - if infoMsg != tt.expectedInfoMsg { - t.Errorf("SwitchDir() infoMsg = %q, want %q", infoMsg, tt.expectedInfoMsg) - } - }) - } -} - -func TestUpdateOutput_NoProcess(t *testing.T) { - m := NewManager() - - // Should not panic with no process - m.UpdateOutput() - - if m.Output() != "" { - t.Error("Output() should be empty when no process exists") - } -} - -func TestOutput(t *testing.T) { - m := NewManager() - - // Initially empty - if m.Output() != "" { - t.Error("initial Output() should be empty") - } - - // Set output directly (simulating what UpdateOutput would do) - m.output = "test output" - if m.Output() != "test output" { - t.Errorf("Output() = %q, want %q", m.Output(), "test output") - } -} - -func TestUpdateOutput_PreservesPreviousOnError(t *testing.T) { - m := NewManager() - - // Simulate having previous output - m.output = "previous output" - - // Create a mock process that returns a timeout error when capture is attempted - mock := &mockCommandRunner{ - blockUntilContextDone: true, // This causes OutputWithContext to block until timeout - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so UpdateOutput actually attempts capture - p.mu.Lock() - p.running = true - p.mu.Unlock() - - m.process = p - - // UpdateOutput should preserve previous output when capture fails (times out) - m.UpdateOutput() - - // Previous output should be preserved - if m.Output() != "previous output" { - t.Errorf("Output() = %q, want %q (should preserve previous on error)", m.Output(), "previous output") - } -} - -func TestResize_NoProcess(t *testing.T) { - m := NewManager() - - // Should not panic with no process - m.Resize() -} - -func TestCleanup_NoProcess(t *testing.T) { - m := NewManager() - - // Should not panic with no process - m.Cleanup() -} - -func TestProcess(t *testing.T) { - m := NewManager() - - // Initially nil - if m.Process() != nil { - t.Error("Process() should be nil initially") - } -} - -func TestUpdateOnInstanceChange_NotInWorktreeMode(t *testing.T) { - m := NewManager() - m.SetDirMode(DirInvocation) - - // Should return empty string and not attempt any changes - errMsg := m.UpdateOnInstanceChange(nil) - if errMsg != "" { - t.Errorf("UpdateOnInstanceChange() = %q, want empty", errMsg) - } -} - -func TestUpdateOnInstanceChange_NoProcess(t *testing.T) { - m := NewManager() - m.SetDirMode(DirWorktree) - - // Should return empty string when no process - errMsg := m.UpdateOnInstanceChange(nil) - if errMsg != "" { - t.Errorf("UpdateOnInstanceChange() = %q, want empty", errMsg) - } -} - -func TestDirModeConstants(t *testing.T) { - // Verify DirInvocation is zero value (default) - if DirInvocation != 0 { - t.Errorf("DirInvocation = %d, want 0", DirInvocation) - } - if DirWorktree != 1 { - t.Errorf("DirWorktree = %d, want 1", DirWorktree) - } -} - -func TestSendPaste_NoProcess(t *testing.T) { - m := NewManager() - - err := m.SendPaste("test text") - if err != ErrNotRunning { - t.Errorf("SendPaste() error = %v, want ErrNotRunning", err) - } -} - -// newTestManagerWithProcess creates a Manager with a running mock process for key tests. -func newTestManagerWithProcess(mock *mockCommandRunner) *Manager { - m := NewManager() - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - p.mu.Lock() - p.running = true - p.mu.Unlock() - m.process = p - return m -} - -func TestSendKey_AltBackspace(t *testing.T) { - mock := &mockCommandRunner{} - m := newTestManagerWithProcess(mock) - - // Send Alt+Backspace (Opt+Backspace on macOS) - m.SendKey(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}) - - // Should send Escape followed by BSpace (two separate keys) - if len(mock.commands) != 2 { - t.Fatalf("SendKey(Alt+Backspace) sent %d commands, want 2", len(mock.commands)) - } - - // First command: Escape - if !containsArg(mock.commands[0].args, "Escape") { - t.Errorf("first command args = %v, want Escape key", mock.commands[0].args) - } - - // Second command: BSpace - if !containsArg(mock.commands[1].args, "BSpace") { - t.Errorf("second command args = %v, want BSpace key", mock.commands[1].args) - } -} - -func TestSendKey_AltArrows(t *testing.T) { - tests := []struct { - name string - keyType tea.KeyType - expectedKey string - }{ - {"Alt+Up", tea.KeyUp, "Up"}, - {"Alt+Down", tea.KeyDown, "Down"}, - {"Alt+Left", tea.KeyLeft, "Left"}, - {"Alt+Right", tea.KeyRight, "Right"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mock := &mockCommandRunner{} - m := newTestManagerWithProcess(mock) - - m.SendKey(tea.KeyMsg{Type: tt.keyType, Alt: true}) - - if len(mock.commands) != 2 { - t.Fatalf("SendKey(%s) sent %d commands, want 2", tt.name, len(mock.commands)) - } - - if !containsArg(mock.commands[0].args, "Escape") { - t.Errorf("first command args = %v, want Escape key", mock.commands[0].args) - } - if !containsArg(mock.commands[1].args, tt.expectedKey) { - t.Errorf("second command args = %v, want %s key", mock.commands[1].args, tt.expectedKey) - } - }) - } -} - -func TestSendKey_PlainBackspace(t *testing.T) { - mock := &mockCommandRunner{} - m := newTestManagerWithProcess(mock) - - // Plain backspace without Alt should send just BSpace - m.SendKey(tea.KeyMsg{Type: tea.KeyBackspace}) - - if len(mock.commands) != 1 { - t.Fatalf("SendKey(Backspace) sent %d commands, want 1", len(mock.commands)) - } - - if !containsArg(mock.commands[0].args, "BSpace") { - t.Errorf("command args = %v, want BSpace key", mock.commands[0].args) - } -} - -// containsArg checks if any of the args slice elements match the given value. -func containsArg(args []string, value string) bool { - return slices.Contains(args, value) -} diff --git a/internal/tui/terminal/process.go b/internal/tui/terminal/process.go deleted file mode 100644 index 73dcd1b..0000000 --- a/internal/tui/terminal/process.go +++ /dev/null @@ -1,485 +0,0 @@ -// Package terminal provides a persistent shell session for the TUI terminal pane. -package terminal - -import ( - "context" - "errors" - "fmt" - "log" - "os" - "os/exec" - "strings" - "sync" - "time" - - "github.com/Iron-Ham/claudio/internal/tmux" -) - -// CommandRunner abstracts tmux command execution for testability. -// In production, this uses real tmux commands. In tests, it can be mocked -// to verify the correct command sequences are executed. -type CommandRunner interface { - // Run executes a tmux command and returns any error. - Run(socketName string, args ...string) error - // Output executes a tmux command and returns its output. - Output(socketName string, args ...string) ([]byte, error) - // OutputWithContext executes a tmux command with context support for cancellation/timeout. - OutputWithContext(ctx context.Context, socketName string, args ...string) ([]byte, error) - // CommandWithEnv returns an exec.Cmd for commands that need environment customization. - CommandWithEnv(socketName string, args ...string) *exec.Cmd -} - -// defaultCommandRunner is the production implementation that executes real tmux commands. -type defaultCommandRunner struct{} - -func (r *defaultCommandRunner) Run(socketName string, args ...string) error { - return tmux.CommandWithSocket(socketName, args...).Run() -} - -func (r *defaultCommandRunner) Output(socketName string, args ...string) ([]byte, error) { - return tmux.CommandWithSocket(socketName, args...).Output() -} - -func (r *defaultCommandRunner) OutputWithContext(ctx context.Context, socketName string, args ...string) ([]byte, error) { - return tmux.CommandContextWithSocket(ctx, socketName, args...).Output() -} - -func (r *defaultCommandRunner) CommandWithEnv(socketName string, args ...string) *exec.Cmd { - return tmux.CommandWithSocket(socketName, args...) -} - -// Common errors for terminal process management. -var ( - ErrAlreadyRunning = errors.New("terminal process is already running") - ErrNotRunning = errors.New("terminal process is not running") - ErrCaptureTimeout = errors.New("terminal output capture timed out") - ErrCaptureKilled = errors.New("terminal output capture was killed") - ErrCaptureCancelled = errors.New("terminal output capture was cancelled") -) - -// captureTimeout is the maximum time to wait for a tmux capture-pane command. -// This prevents the TUI from freezing if tmux becomes unresponsive. -const captureTimeout = 500 * time.Millisecond - -// Process manages a persistent shell session in a tmux session for the terminal pane. -// Unlike the instance TmuxProcess, this runs a plain shell (no backend command). -type Process struct { - sessionName string // tmux session name - socketName string // tmux socket for crash isolation - invocationDir string // Directory where Claudio was invoked (never changes) - currentDir string // Current working directory - width int - height int - cmdRunner CommandRunner // abstraction for tmux command execution - - mu sync.RWMutex - running bool -} - -// NewProcess creates a new terminal process manager. -// sessionID should be the Claudio session ID to ensure unique tmux session names. -// invocationDir is the directory where Claudio was launched. -// Uses the default "claudio" socket for the terminal pane, keeping it separate -// from per-instance sockets to isolate terminal from instance crashes. -func NewProcess(sessionID, invocationDir string, width, height int) *Process { - return &Process{ - sessionName: fmt.Sprintf("claudio-term-%s", sessionID), - socketName: tmux.SocketName, // Use shared socket for terminal pane - invocationDir: invocationDir, - currentDir: invocationDir, - width: width, - height: height, - cmdRunner: &defaultCommandRunner{}, - } -} - -// NewProcessWithSocket creates a new terminal process manager with a specific socket. -// This allows explicit control over socket isolation. -func NewProcessWithSocket(sessionID, socketName, invocationDir string, width, height int) *Process { - return &Process{ - sessionName: fmt.Sprintf("claudio-term-%s", sessionID), - socketName: socketName, - invocationDir: invocationDir, - currentDir: invocationDir, - width: width, - height: height, - cmdRunner: &defaultCommandRunner{}, - } -} - -// NewProcessWithRunner creates a new terminal process manager with a custom command runner. -// This is primarily used for testing to inject mock command execution. -func NewProcessWithRunner(sessionID, socketName, invocationDir string, width, height int, runner CommandRunner) *Process { - return &Process{ - sessionName: fmt.Sprintf("claudio-term-%s", sessionID), - socketName: socketName, - invocationDir: invocationDir, - currentDir: invocationDir, - width: width, - height: height, - cmdRunner: runner, - } -} - -// Start launches the terminal shell in a tmux session. -func (p *Process) Start() error { - p.mu.Lock() - defer p.mu.Unlock() - return p.startLocked() -} - -// startLocked is the internal start implementation that assumes the lock is already held. -func (p *Process) startLocked() error { - if p.running { - return ErrAlreadyRunning - } - - // Kill any existing session with this name (cleanup from previous run) - if err := p.cmdRunner.Run(p.socketName, "kill-session", "-t", p.sessionName); err != nil { - if !isSessionNotFoundError(err) { - log.Printf("WARNING: failed to cleanup existing terminal tmux session %s: %v", p.sessionName, err) - } - } - - // Determine dimensions - width := p.width - if width == 0 { - width = 200 - } - height := p.height - if height == 0 { - height = 10 - } - - // Set history-limit BEFORE creating session so the new pane inherits it. - // tmux's history-limit only affects newly created panes, not existing ones. - if err := p.cmdRunner.Run(p.socketName, "set-option", "-g", "history-limit", "50000"); err != nil { - log.Printf("WARNING: failed to set global history-limit for tmux: %v", err) - } - - // Create a new detached tmux session with proper environment setup. - // We set TERM=xterm-256color in the environment so the shell inherits it directly, - // which avoids modifying global tmux state that could affect other sessions. - createCmd := p.cmdRunner.CommandWithEnv(p.socketName, - "new-session", - "-d", - "-s", p.sessionName, - "-x", fmt.Sprintf("%d", width), - "-y", fmt.Sprintf("%d", height), - "-c", p.currentDir, // Start in the current directory - ) - createCmd.Dir = p.currentDir - createCmd.Env = append(os.Environ(), "TERM=xterm-256color") - if err := createCmd.Run(); err != nil { - return fmt.Errorf("failed to create terminal tmux session: %w", err) - } - - // Set default-terminal per-session (not globally) for any new panes/windows in this session. - // This ensures consistent terminal emulation without affecting other tmux sessions. - if err := p.cmdRunner.Run(p.socketName, "set-option", "-t", p.sessionName, "default-terminal", "xterm-256color"); err != nil { - log.Printf("WARNING: failed to set default-terminal for terminal tmux session %s: %v", p.sessionName, err) - } - - p.running = true - - return nil -} - -// Stop terminates the terminal session. -func (p *Process) Stop() error { - p.mu.Lock() - defer p.mu.Unlock() - - if !p.running { - return nil - } - - // Kill the tmux session - if err := p.cmdRunner.Run(p.socketName, "kill-session", "-t", p.sessionName); err != nil { - if !isSessionNotFoundError(err) { - log.Printf("WARNING: unexpected error killing terminal tmux session %s: %v", p.sessionName, err) - } - } - - p.running = false - return nil -} - -// IsRunning returns whether the terminal process is currently running. -func (p *Process) IsRunning() bool { - p.mu.RLock() - defer p.mu.RUnlock() - return p.running -} - -// SessionExists checks if the tmux session still exists. -func (p *Process) SessionExists() bool { - p.mu.RLock() - defer p.mu.RUnlock() - return p.sessionExists() -} - -func (p *Process) sessionExists() bool { - return p.cmdRunner.Run(p.socketName, "has-session", "-t", p.sessionName) == nil -} - -// EnsureRunning starts the process if it's not running, or restarts if the session died. -func (p *Process) EnsureRunning() error { - p.mu.Lock() - defer p.mu.Unlock() - - // If we think it's running, verify the session exists - if p.running && !p.sessionExists() { - p.running = false - } - - if p.running { - return nil - } - - return p.startLocked() -} - -// ChangeDirectory changes the terminal's working directory. -func (p *Process) ChangeDirectory(dir string) error { - p.mu.Lock() - defer p.mu.Unlock() - - if !p.running { - // Just update the stored directory; it will be used when we start - p.currentDir = dir - return nil - } - - // Send cd command to the terminal - cdCmd := fmt.Sprintf("cd %q", dir) - if err := p.cmdRunner.Run(p.socketName, "send-keys", "-t", p.sessionName, cdCmd, "Enter"); err != nil { - return fmt.Errorf("failed to change directory: %w", err) - } - - p.currentDir = dir - return nil -} - -// CurrentDir returns the current working directory of the terminal. -func (p *Process) CurrentDir() string { - p.mu.RLock() - defer p.mu.RUnlock() - return p.currentDir -} - -// InvocationDir returns the directory where Claudio was invoked. -func (p *Process) InvocationDir() string { - return p.invocationDir -} - -// SendKey sends a special key (like "Enter", "C-c", "Up") to the terminal. -func (p *Process) SendKey(key string) error { - p.mu.RLock() - running := p.running - sessionName := p.sessionName - p.mu.RUnlock() - - if !running { - return ErrNotRunning - } - - if err := p.cmdRunner.Run(p.socketName, "send-keys", "-t", sessionName, key); err != nil { - return fmt.Errorf("failed to send key to terminal: %w", err) - } - return nil -} - -// SendLiteral sends literal text to the terminal (characters sent as-is). -func (p *Process) SendLiteral(text string) error { - p.mu.RLock() - running := p.running - sessionName := p.sessionName - p.mu.RUnlock() - - if !running { - return ErrNotRunning - } - - if err := p.cmdRunner.Run(p.socketName, "send-keys", "-t", sessionName, "-l", text); err != nil { - return fmt.Errorf("failed to send literal to terminal: %w", err) - } - return nil -} - -// SendPaste sends pasted text with bracketed paste sequences. -func (p *Process) SendPaste(text string) error { - p.mu.RLock() - running := p.running - sessionName := p.sessionName - p.mu.RUnlock() - - if !running { - return ErrNotRunning - } - - // Send bracketed paste start sequence - if err := p.cmdRunner.Run(p.socketName, "send-keys", "-t", sessionName, "-l", "\x1b[200~"); err != nil { - return fmt.Errorf("failed to send paste start: %w", err) - } - - // Send the pasted content - if err := p.cmdRunner.Run(p.socketName, "send-keys", "-t", sessionName, "-l", text); err != nil { - return fmt.Errorf("failed to send paste content: %w", err) - } - - // Send bracketed paste end sequence - if err := p.cmdRunner.Run(p.socketName, "send-keys", "-t", sessionName, "-l", "\x1b[201~"); err != nil { - return fmt.Errorf("failed to send paste end: %w", err) - } - - return nil -} - -// CaptureOutput captures the current visible content of the terminal pane. -// Uses a timeout to prevent blocking the TUI if tmux is unresponsive. -func (p *Process) CaptureOutput() (string, error) { - p.mu.RLock() - running := p.running - sessionName := p.sessionName - p.mu.RUnlock() - - if !running { - return "", ErrNotRunning - } - - // Create a context with timeout to prevent blocking the TUI event loop - // if tmux becomes unresponsive (e.g., due to socket issues, session not found) - ctx, cancel := context.WithTimeout(context.Background(), captureTimeout) - defer cancel() - - // Capture visible pane content with escape sequences preserved (-e flag) - // The -e flag preserves ANSI color codes and other escape sequences - output, err := p.cmdRunner.OutputWithContext(ctx, p.socketName, "capture-pane", "-t", sessionName, "-p", "-e") - if err != nil { - // Distinguish between timeout and other errors for better diagnostics - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return "", ErrCaptureTimeout - } - // Coverage: ErrCaptureCancelled is defensive code. Currently unreachable because - // the context is created from context.Background() (which cannot be cancelled) - // and context.WithTimeout only triggers context.DeadlineExceeded on expiry. - // Kept for future-proofing if this function is refactored to accept an external context. - if errors.Is(ctx.Err(), context.Canceled) { - return "", ErrCaptureCancelled - } - // Check if the process was killed (happens when context times out) - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == -1 { - return "", ErrCaptureKilled - } - return "", fmt.Errorf("failed to capture terminal output: %w", err) - } - - return string(output), nil -} - -// CaptureOutputWithHistory captures terminal content including scrollback history. -// Uses a timeout to prevent blocking the TUI if tmux is unresponsive. -func (p *Process) CaptureOutputWithHistory(lines int) (string, error) { - p.mu.RLock() - running := p.running - sessionName := p.sessionName - p.mu.RUnlock() - - if !running { - return "", ErrNotRunning - } - - // Create a context with timeout to prevent blocking the TUI event loop - ctx, cancel := context.WithTimeout(context.Background(), captureTimeout) - defer cancel() - - // Capture with history (-S for start line, negative means history) - // The -e flag preserves ANSI color codes and other escape sequences - output, err := p.cmdRunner.OutputWithContext(ctx, p.socketName, "capture-pane", "-t", sessionName, "-p", "-e", "-S", fmt.Sprintf("-%d", lines)) - if err != nil { - // Distinguish between timeout and other errors for better diagnostics - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return "", ErrCaptureTimeout - } - // Coverage: ErrCaptureCancelled is defensive code. Currently unreachable because - // the context is created from context.Background() (which cannot be cancelled) - // and context.WithTimeout only triggers context.DeadlineExceeded on expiry. - // Kept for future-proofing if this function is refactored to accept an external context. - if errors.Is(ctx.Err(), context.Canceled) { - return "", ErrCaptureCancelled - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == -1 { - return "", ErrCaptureKilled - } - return "", fmt.Errorf("failed to capture terminal output with history: %w", err) - } - - return string(output), nil -} - -// Resize adjusts the terminal dimensions. -func (p *Process) Resize(width, height int) error { - p.mu.Lock() - p.width = width - p.height = height - running := p.running - sessionName := p.sessionName - p.mu.Unlock() - - if !running { - return nil - } - - if err := p.cmdRunner.Run(p.socketName, "resize-window", "-t", sessionName, "-x", fmt.Sprintf("%d", width), "-y", fmt.Sprintf("%d", height)); err != nil { - return fmt.Errorf("failed to resize terminal: %w", err) - } - - return nil -} - -// AttachCommand returns the command to attach to this terminal's tmux session. -func (p *Process) AttachCommand() string { - return fmt.Sprintf("tmux -L %s attach -t %s", p.socketName, p.sessionName) -} - -// SocketName returns the tmux socket name used for this terminal. -func (p *Process) SocketName() string { - return p.socketName -} - -// SessionName returns the tmux session name. -func (p *Process) SessionName() string { - return p.sessionName -} - -// WaitForPrompt waits for the shell prompt to appear (basic readiness check). -func (p *Process) WaitForPrompt(timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - output, err := p.CaptureOutput() - if err != nil { - time.Sleep(50 * time.Millisecond) - continue - } - // Check for common prompt indicators - if strings.Contains(output, "$") || strings.Contains(output, "%") || strings.Contains(output, ">") { - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("timeout waiting for shell prompt") -} - -// isSessionNotFoundError checks if the error indicates a tmux session was not found. -func isSessionNotFoundError(err error) bool { - if err == nil { - return false - } - errStr := err.Error() - return strings.Contains(errStr, "session not found") || - strings.Contains(errStr, "no server running") || - strings.Contains(errStr, "can't find session") -} diff --git a/internal/tui/terminal/process_test.go b/internal/tui/terminal/process_test.go deleted file mode 100644 index b18e6fc..0000000 --- a/internal/tui/terminal/process_test.go +++ /dev/null @@ -1,606 +0,0 @@ -package terminal - -import ( - "context" - "os/exec" - "slices" - "strings" - "testing" - - "github.com/Iron-Ham/claudio/internal/tmux" -) - -// mockCommandRunner records all tmux commands for verification in tests. -type mockCommandRunner struct { - commands []mockCommand // recorded commands - runErr error // error to return from Run - outputResult []byte // result to return from Output - outputErr error // error to return from Output - blockUntilContextDone bool // if true, OutputWithContext blocks until context is done -} - -// mockCommand represents a recorded tmux command. -type mockCommand struct { - socketName string - args []string - hasEnv bool // whether this was from CommandWithEnv -} - -func (m *mockCommandRunner) Run(socketName string, args ...string) error { - m.commands = append(m.commands, mockCommand{ - socketName: socketName, - args: args, - }) - return m.runErr -} - -func (m *mockCommandRunner) Output(socketName string, args ...string) ([]byte, error) { - m.commands = append(m.commands, mockCommand{ - socketName: socketName, - args: args, - }) - return m.outputResult, m.outputErr -} - -func (m *mockCommandRunner) OutputWithContext(ctx context.Context, socketName string, args ...string) ([]byte, error) { - m.commands = append(m.commands, mockCommand{ - socketName: socketName, - args: args, - }) - - // If blockUntilContextDone is set, wait for the context to be done. - // This simulates a tmux command that hangs until the timeout expires, - // which is the real-world scenario where ctx.Err() returns DeadlineExceeded. - if m.blockUntilContextDone { - <-ctx.Done() - return nil, ctx.Err() - } - - return m.outputResult, m.outputErr -} - -func (m *mockCommandRunner) CommandWithEnv(socketName string, args ...string) *exec.Cmd { - // Record the command but return a real exec.Cmd that we can inspect - // We use /bin/sh -c true which ignores Dir settings that might not exist - cmd := exec.Command("/bin/sh", "-c", "true") - m.commands = append(m.commands, mockCommand{ - socketName: socketName, - args: args, - hasEnv: true, - }) - return cmd -} - -func TestNewProcess(t *testing.T) { - p := NewProcess("session123", "/tmp", 100, 50) - - if p == nil { - t.Fatal("NewProcess returned nil") - } - - // Should use the default socket - if got := p.SocketName(); got != tmux.SocketName { - t.Errorf("SocketName() = %q, want default %q", got, tmux.SocketName) - } - - // Session name should be formatted correctly - expectedSession := "claudio-term-session123" - if got := p.SessionName(); got != expectedSession { - t.Errorf("SessionName() = %q, want %q", got, expectedSession) - } -} - -func TestNewProcessWithSocket(t *testing.T) { - customSocket := "claudio-custom456" - p := NewProcessWithSocket("session123", customSocket, "/tmp", 100, 50) - - if p == nil { - t.Fatal("NewProcessWithSocket returned nil") - } - - // Should use the custom socket - if got := p.SocketName(); got != customSocket { - t.Errorf("SocketName() = %q, want %q", got, customSocket) - } - - // Session name should still be formatted correctly - expectedSession := "claudio-term-session123" - if got := p.SessionName(); got != expectedSession { - t.Errorf("SessionName() = %q, want %q", got, expectedSession) - } -} - -func TestProcess_SocketName(t *testing.T) { - tests := []struct { - name string - socketName string - }{ - { - name: "default socket", - socketName: tmux.SocketName, - }, - { - name: "custom instance socket", - socketName: "claudio-abc123", - }, - { - name: "another custom socket", - socketName: "claudio-xyz789", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewProcessWithSocket("test-session", tt.socketName, "/tmp", 100, 50) - if got := p.SocketName(); got != tt.socketName { - t.Errorf("SocketName() = %q, want %q", got, tt.socketName) - } - }) - } -} - -func TestProcess_AttachCommand(t *testing.T) { - tests := []struct { - name string - sessionID string - socketName string - wantCommand string - }{ - { - name: "default socket", - sessionID: "sess1", - socketName: tmux.SocketName, - wantCommand: "tmux -L claudio attach -t claudio-term-sess1", - }, - { - name: "custom socket", - sessionID: "sess2", - socketName: "claudio-custom", - wantCommand: "tmux -L claudio-custom attach -t claudio-term-sess2", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewProcessWithSocket(tt.sessionID, tt.socketName, "/tmp", 100, 50) - if got := p.AttachCommand(); got != tt.wantCommand { - t.Errorf("AttachCommand() = %q, want %q", got, tt.wantCommand) - } - }) - } -} - -func TestProcess_IsRunning_Initial(t *testing.T) { - p := NewProcess("test", "/tmp", 100, 50) - - if p.IsRunning() { - t.Error("New process should not be running") - } -} - -func TestProcess_CurrentDir(t *testing.T) { - invocationDir := "/home/user/project" - p := NewProcess("test", invocationDir, 100, 50) - - if got := p.CurrentDir(); got != invocationDir { - t.Errorf("CurrentDir() = %q, want %q", got, invocationDir) - } - - if got := p.InvocationDir(); got != invocationDir { - t.Errorf("InvocationDir() = %q, want %q", got, invocationDir) - } -} - -func TestProcess_Start_TmuxCommandSequence(t *testing.T) { - mock := &mockCommandRunner{} - // Use /tmp as it's guaranteed to exist on all Unix systems - p := NewProcessWithRunner("test-session", "test-socket", "/tmp", 100, 50, mock) - - err := p.Start() - if err != nil { - t.Fatalf("Start() returned unexpected error: %v", err) - } - - // Verify the correct sequence of tmux commands was executed - // Expected sequence: - // 1. kill-session (cleanup any existing session) - // 2. set-option -g history-limit 50000 - // 3. new-session (via CommandWithEnv for environment variable support) - // 4. set-option -t default-terminal xterm-256color - - if len(mock.commands) != 4 { - t.Fatalf("Expected 4 tmux commands, got %d: %+v", len(mock.commands), mock.commands) - } - - // Command 1: kill-session cleanup - cmd := mock.commands[0] - if cmd.socketName != "test-socket" { - t.Errorf("Command 1: socket = %q, want %q", cmd.socketName, "test-socket") - } - if len(cmd.args) < 1 || cmd.args[0] != "kill-session" { - t.Errorf("Command 1: expected kill-session, got %v", cmd.args) - } - - // Command 2: set history-limit globally (before session creation) - cmd = mock.commands[1] - expectedArgs := []string{"set-option", "-g", "history-limit", "50000"} - if !slices.Equal(cmd.args, expectedArgs) { - t.Errorf("Command 2: args = %v, want %v", cmd.args, expectedArgs) - } - - // Command 3: new-session with proper dimensions (via CommandWithEnv) - cmd = mock.commands[2] - if !cmd.hasEnv { - t.Errorf("Command 3: expected CommandWithEnv to be used for new-session") - } - if len(cmd.args) < 1 || cmd.args[0] != "new-session" { - t.Errorf("Command 3: expected new-session, got %v", cmd.args) - } - // Verify session name and dimensions are in args - argsStr := strings.Join(cmd.args, " ") - if !strings.Contains(argsStr, "claudio-term-test-session") { - t.Errorf("Command 3: session name not found in args: %v", cmd.args) - } - if !strings.Contains(argsStr, "-x 100") { - t.Errorf("Command 3: width not found in args: %v", cmd.args) - } - if !strings.Contains(argsStr, "-y 50") { - t.Errorf("Command 3: height not found in args: %v", cmd.args) - } - - // Command 4: set default-terminal per-session (not global) - cmd = mock.commands[3] - if cmd.args[0] != "set-option" { - t.Errorf("Command 4: expected set-option, got %v", cmd.args) - } - // Should use -t (per-session) not -g (global) - if !slices.Contains(cmd.args, "-t") { - t.Errorf("Command 4: expected -t flag for per-session option, got %v", cmd.args) - } - if slices.Contains(cmd.args, "-g") { - t.Errorf("Command 4: should not use -g (global) for default-terminal, got %v", cmd.args) - } - if !slices.Contains(cmd.args, "default-terminal") || !slices.Contains(cmd.args, "xterm-256color") { - t.Errorf("Command 4: expected default-terminal xterm-256color, got %v", cmd.args) - } - - // Verify running state - if !p.IsRunning() { - t.Error("Process should be running after Start()") - } -} - -func TestProcess_Start_DefaultDimensions(t *testing.T) { - mock := &mockCommandRunner{} - // Use 0 for width and height to test default values - p := NewProcessWithRunner("test-session", "test-socket", "/tmp", 0, 0, mock) - - err := p.Start() - if err != nil { - t.Fatalf("Start() returned unexpected error: %v", err) - } - - // Find the new-session command - var newSessionCmd *mockCommand - for i := range mock.commands { - if len(mock.commands[i].args) > 0 && mock.commands[i].args[0] == "new-session" { - newSessionCmd = &mock.commands[i] - break - } - } - - if newSessionCmd == nil { - t.Fatal("new-session command not found") - } - - // Check for default dimensions (200x10) - argsStr := strings.Join(newSessionCmd.args, " ") - if !strings.Contains(argsStr, "-x 200") { - t.Errorf("Expected default width 200, got args: %v", newSessionCmd.args) - } - if !strings.Contains(argsStr, "-y 10") { - t.Errorf("Expected default height 10, got args: %v", newSessionCmd.args) - } -} - -func TestProcess_Start_AlreadyRunning(t *testing.T) { - mock := &mockCommandRunner{} - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Start the process - if err := p.Start(); err != nil { - t.Fatalf("First Start() failed: %v", err) - } - - // Try to start again - err := p.Start() - if err != ErrAlreadyRunning { - t.Errorf("Second Start() = %v, want ErrAlreadyRunning", err) - } -} - -func TestProcess_Stop_TmuxCommand(t *testing.T) { - mock := &mockCommandRunner{} - p := NewProcessWithRunner("test", "test-socket", "/tmp", 100, 50, mock) - - // Start the process first - if err := p.Start(); err != nil { - t.Fatalf("Start() failed: %v", err) - } - - // Clear recorded commands - mock.commands = nil - - // Stop the process - if err := p.Stop(); err != nil { - t.Fatalf("Stop() failed: %v", err) - } - - // Verify kill-session command was sent - if len(mock.commands) != 1 { - t.Fatalf("Expected 1 command (kill-session), got %d", len(mock.commands)) - } - - cmd := mock.commands[0] - if cmd.args[0] != "kill-session" { - t.Errorf("Expected kill-session, got %v", cmd.args) - } - if !slices.Contains(cmd.args, "claudio-term-test") { - t.Errorf("Expected session name in kill-session args, got %v", cmd.args) - } - - // Verify not running - if p.IsRunning() { - t.Error("Process should not be running after Stop()") - } -} - -func TestProcess_NoMouseOrAggressiveResize(t *testing.T) { - // This test ensures we don't add mouse support or aggressive-resize - // as those were identified as scope creep in the review. - mock := &mockCommandRunner{} - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - if err := p.Start(); err != nil { - t.Fatalf("Start() failed: %v", err) - } - - // Verify no mouse or aggressive-resize commands were sent - for _, cmd := range mock.commands { - argsStr := strings.Join(cmd.args, " ") - if strings.Contains(argsStr, "mouse") { - t.Errorf("Unexpected mouse command found: %v", cmd.args) - } - if strings.Contains(argsStr, "aggressive-resize") { - t.Errorf("Unexpected aggressive-resize command found: %v", cmd.args) - } - } -} - -func TestNewProcessWithRunner(t *testing.T) { - mock := &mockCommandRunner{} - p := NewProcessWithRunner("session123", "custom-socket", "/home/user", 80, 24, mock) - - if p == nil { - t.Fatal("NewProcessWithRunner returned nil") - } - - if got := p.SocketName(); got != "custom-socket" { - t.Errorf("SocketName() = %q, want %q", got, "custom-socket") - } - - expectedSession := "claudio-term-session123" - if got := p.SessionName(); got != expectedSession { - t.Errorf("SessionName() = %q, want %q", got, expectedSession) - } -} - -func TestProcess_CaptureOutput_UsesContextForTimeout(t *testing.T) { - mock := &mockCommandRunner{ - outputResult: []byte("terminal output"), - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so capture is attempted - p.mu.Lock() - p.running = true - p.mu.Unlock() - - output, err := p.CaptureOutput() - if err != nil { - t.Fatalf("CaptureOutput() returned unexpected error: %v", err) - } - - if output != "terminal output" { - t.Errorf("CaptureOutput() = %q, want %q", output, "terminal output") - } - - // Verify capture-pane command was called - found := false - for _, cmd := range mock.commands { - if slices.Contains(cmd.args, "capture-pane") { - found = true - break - } - } - if !found { - t.Error("Expected capture-pane command to be called") - } -} - -func TestProcess_CaptureOutput_ReturnsErrCaptureTimeout(t *testing.T) { - // To properly test ErrCaptureTimeout, the mock must block until the context - // times out. This causes ctx.Err() to return context.DeadlineExceeded, which - // triggers the ErrCaptureTimeout code path. - mock := &mockCommandRunner{ - blockUntilContextDone: true, - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so capture is attempted - p.mu.Lock() - p.running = true - p.mu.Unlock() - - // CaptureOutput has a 500ms timeout (captureTimeout constant). - // The mock will block until that timeout expires, then return ctx.Err(). - _, err := p.CaptureOutput() - if err != ErrCaptureTimeout { - t.Errorf("CaptureOutput() error = %v, want ErrCaptureTimeout", err) - } -} - -func TestProcess_CaptureOutput_NotRunning(t *testing.T) { - mock := &mockCommandRunner{} - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Process not running - should return ErrNotRunning - output, err := p.CaptureOutput() - if err != ErrNotRunning { - t.Errorf("CaptureOutput() error = %v, want ErrNotRunning", err) - } - if output != "" { - t.Errorf("CaptureOutput() = %q, want empty string", output) - } -} - -func TestProcess_CaptureOutputWithHistory_UsesContextForTimeout(t *testing.T) { - mock := &mockCommandRunner{ - outputResult: []byte("terminal output with history"), - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so capture is attempted - p.mu.Lock() - p.running = true - p.mu.Unlock() - - output, err := p.CaptureOutputWithHistory(100) - if err != nil { - t.Fatalf("CaptureOutputWithHistory() returned unexpected error: %v", err) - } - - if output != "terminal output with history" { - t.Errorf("CaptureOutputWithHistory() = %q, want %q", output, "terminal output with history") - } - - // Verify capture-pane command was called with history flag - found := false - for _, cmd := range mock.commands { - if slices.Contains(cmd.args, "capture-pane") && slices.Contains(cmd.args, "-S") { - found = true - break - } - } - if !found { - t.Error("Expected capture-pane command with -S flag to be called") - } -} - -func TestProcess_CaptureOutput_ReturnsErrCaptureKilled(t *testing.T) { - // Create a real exec.ExitError with exit code -1 by running a process - // that gets killed by a signal. This simulates what happens when tmux - // capture-pane is killed due to context cancellation. - killedErr := createSignalKilledError(t) - if killedErr == nil { - t.Skip("Could not create signal-killed error on this platform") - } - - mock := &mockCommandRunner{ - outputErr: killedErr, - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so capture is attempted - p.mu.Lock() - p.running = true - p.mu.Unlock() - - _, err := p.CaptureOutput() - if err != ErrCaptureKilled { - t.Errorf("CaptureOutput() error = %v, want ErrCaptureKilled", err) - } -} - -func TestProcess_CaptureOutputWithHistory_ReturnsErrCaptureTimeout(t *testing.T) { - // Test that CaptureOutputWithHistory also returns ErrCaptureTimeout when - // the context times out. - mock := &mockCommandRunner{ - blockUntilContextDone: true, - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so capture is attempted - p.mu.Lock() - p.running = true - p.mu.Unlock() - - _, err := p.CaptureOutputWithHistory(100) - if err != ErrCaptureTimeout { - t.Errorf("CaptureOutputWithHistory() error = %v, want ErrCaptureTimeout", err) - } -} - -func TestProcess_CaptureOutputWithHistory_ReturnsErrCaptureKilled(t *testing.T) { - killedErr := createSignalKilledError(t) - if killedErr == nil { - t.Skip("Could not create signal-killed error on this platform") - } - - mock := &mockCommandRunner{ - outputErr: killedErr, - } - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Mark as running so capture is attempted - p.mu.Lock() - p.running = true - p.mu.Unlock() - - _, err := p.CaptureOutputWithHistory(100) - if err != ErrCaptureKilled { - t.Errorf("CaptureOutputWithHistory() error = %v, want ErrCaptureKilled", err) - } -} - -func TestProcess_CaptureOutputWithHistory_NotRunning(t *testing.T) { - mock := &mockCommandRunner{} - p := NewProcessWithRunner("test", "socket", "/tmp", 100, 50, mock) - - // Process not running - should return ErrNotRunning - output, err := p.CaptureOutputWithHistory(100) - if err != ErrNotRunning { - t.Errorf("CaptureOutputWithHistory() error = %v, want ErrNotRunning", err) - } - if output != "" { - t.Errorf("CaptureOutputWithHistory() = %q, want empty string", output) - } -} - -// createSignalKilledError creates an exec.ExitError with exit code -1 by running -// a process that kills itself with a signal. This is used to test the ErrCaptureKilled -// error path which handles processes killed by context cancellation. -func createSignalKilledError(t *testing.T) *exec.ExitError { - t.Helper() - - // Run a shell command that immediately kills itself with SIGKILL. - // This produces an ExitError with ExitCode() == -1. - cmd := exec.Command("/bin/sh", "-c", "kill -9 $$") - err := cmd.Run() - if err == nil { - return nil - } - - exitErr, ok := err.(*exec.ExitError) - if !ok { - return nil - } - - // Verify it has the expected exit code - if exitErr.ExitCode() != -1 { - t.Logf("Warning: expected exit code -1, got %d", exitErr.ExitCode()) - return nil - } - - return exitErr -} diff --git a/internal/tui/terminal_test.go b/internal/tui/terminal_test.go deleted file mode 100644 index e958be0..0000000 --- a/internal/tui/terminal_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package tui - -import ( - "testing" - - "github.com/Iron-Ham/claudio/internal/tui/terminal" -) - -func TestTerminalHeightConstants(t *testing.T) { - // Verify the terminal height constants are set to reasonable values - t.Run("DefaultPaneHeight is at least MinPaneHeight", func(t *testing.T) { - if terminal.DefaultPaneHeight < terminal.MinPaneHeight { - t.Errorf("DefaultPaneHeight (%d) should be >= MinPaneHeight (%d)", - terminal.DefaultPaneHeight, terminal.MinPaneHeight) - } - }) - - t.Run("DefaultPaneHeight is reasonable", func(t *testing.T) { - // The default height should be at least 10 lines to be useful - if terminal.DefaultPaneHeight < 10 { - t.Errorf("DefaultPaneHeight (%d) should be >= 10 for usability", - terminal.DefaultPaneHeight) - } - }) - - t.Run("MinPaneHeight allows for content", func(t *testing.T) { - // Minimum height should account for border (2) + header (1) + at least 1 content line - // So minimum should be at least 4 - if terminal.MinPaneHeight < 4 { - t.Errorf("MinPaneHeight (%d) should be >= 4 to allow for border, header, and content", - terminal.MinPaneHeight) - } - }) - - t.Run("MaxPaneHeightRatio is sensible", func(t *testing.T) { - if terminal.MaxPaneHeightRatio <= 0 || terminal.MaxPaneHeightRatio > 0.8 { - t.Errorf("MaxPaneHeightRatio (%f) should be between 0 and 0.8", - terminal.MaxPaneHeightRatio) - } - }) -} - -func TestTerminalPaneHeight(t *testing.T) { - tests := []struct { - name string - visible bool - paneHeight int - terminalHeight int // total terminal height for max ratio calculation - expectedHeight int - }{ - { - name: "returns 0 when terminal not visible", - visible: false, - paneHeight: 15, - terminalHeight: 100, - expectedHeight: 0, - }, - { - name: "returns default height when visible and paneHeight is 0", - visible: true, - paneHeight: 0, - terminalHeight: 100, - expectedHeight: terminal.DefaultPaneHeight, - }, - { - name: "returns stored height when visible and height is set", - visible: true, - paneHeight: 20, - terminalHeight: 100, - expectedHeight: 20, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := Model{ - terminalManager: terminal.NewManager(), - } - m.terminalManager.SetSize(80, tt.terminalHeight) - m.terminalManager.SetPaneHeight(tt.paneHeight) - if tt.visible { - m.terminalManager.SetLayout(terminal.LayoutVisible) - } else { - m.terminalManager.SetLayout(terminal.LayoutHidden) - } - - got := m.TerminalPaneHeight() - if got != tt.expectedHeight { - t.Errorf("TerminalPaneHeight() = %d, want %d", got, tt.expectedHeight) - } - }) - } -} - -func TestIsTerminalMode(t *testing.T) { - tests := []struct { - name string - visible bool - focused bool - expected bool - }{ - { - name: "returns true when visible and focused", - visible: true, - focused: true, - expected: true, - }, - { - name: "returns false when visible but not focused", - visible: true, - focused: false, - expected: false, - }, - { - name: "returns false when not visible", - visible: false, - focused: false, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := Model{ - terminalManager: terminal.NewManager(), - } - if tt.visible { - m.terminalManager.SetLayout(terminal.LayoutVisible) - } - if tt.focused { - m.terminalManager.SetFocused(true) - } - - got := m.IsTerminalMode() - if got != tt.expected { - t.Errorf("IsTerminalMode() = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestIsTerminalVisible(t *testing.T) { - tests := []struct { - name string - visible bool - expected bool - }{ - { - name: "returns true when terminal visible", - visible: true, - expected: true, - }, - { - name: "returns false when terminal not visible", - visible: false, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := Model{ - terminalManager: terminal.NewManager(), - } - if tt.visible { - m.terminalManager.SetLayout(terminal.LayoutVisible) - } - - got := m.IsTerminalVisible() - if got != tt.expected { - t.Errorf("IsTerminalVisible() = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestTerminalContentDimensionCalculation(t *testing.T) { - // This test verifies that the content dimensions are calculated correctly - // for the tmux session. The content area should account for: - // - Border: 2 lines (top + bottom) - // - Header: 1 line - // - Border width: 2 chars (left + right) - // - Padding width: 2 chars (left + right) - tests := []struct { - name string - paneHeight int - paneWidth int - expectedContentHeight int - expectedContentWidth int - }{ - { - name: "standard terminal pane", - paneHeight: 15, - paneWidth: 100, - expectedContentHeight: 12, // 15 - 3 - expectedContentWidth: 96, // 100 - 4 - }, - { - name: "minimum height pane", - paneHeight: 5, - paneWidth: 80, - expectedContentHeight: 3, // max(5 - 3, 3) = 3 (minimum enforced) - expectedContentWidth: 76, - }, - { - name: "very small pane height", - paneHeight: 3, - paneWidth: 40, - expectedContentHeight: 3, // Minimum is 3 - expectedContentWidth: 36, - }, - { - name: "narrow pane width", - paneHeight: 10, - paneWidth: 24, - expectedContentHeight: 7, - expectedContentWidth: 20, // Minimum is 20 - }, - { - name: "very narrow pane", - paneHeight: 10, - paneWidth: 10, - expectedContentHeight: 7, - expectedContentWidth: 20, // Clamped to minimum of 20 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Calculate content height (matches resizeTerminal logic) - contentHeight := tt.paneHeight - 3 - if contentHeight < 3 { - contentHeight = 3 - } - - // Calculate content width (matches resizeTerminal logic) - contentWidth := tt.paneWidth - 4 - if contentWidth < 20 { - contentWidth = 20 - } - - if contentHeight != tt.expectedContentHeight { - t.Errorf("contentHeight = %d, want %d (paneHeight=%d)", - contentHeight, tt.expectedContentHeight, tt.paneHeight) - } - - if contentWidth != tt.expectedContentWidth { - t.Errorf("contentWidth = %d, want %d (paneWidth=%d)", - contentWidth, tt.expectedContentWidth, tt.paneWidth) - } - }) - } -} - -func TestEnterTerminalMode(t *testing.T) { - tests := []struct { - name string - visible bool - expectFocusedAfter bool - }{ - { - name: "does not enter when terminal not visible", - visible: false, - expectFocusedAfter: false, - }, - { - name: "does not enter when no terminal process", - visible: true, - expectFocusedAfter: false, // No process, so enterTerminalMode does nothing - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := Model{ - terminalManager: terminal.NewManager(), - // terminalProcess is nil, which means IsRunning() would panic - // but enterTerminalMode checks terminalProcess != nil first - } - if tt.visible { - m.terminalManager.SetLayout(terminal.LayoutVisible) - } - - m.enterTerminalMode() - - if m.terminalManager.IsFocused() != tt.expectFocusedAfter { - t.Errorf("IsFocused() = %v, want %v", m.terminalManager.IsFocused(), tt.expectFocusedAfter) - } - }) - } -} - -func TestExitTerminalMode(t *testing.T) { - m := Model{ - terminalManager: terminal.NewManager(), - } - m.terminalManager.SetLayout(terminal.LayoutVisible) - m.terminalManager.SetFocused(true) - - m.exitTerminalMode() - - if m.terminalManager.IsFocused() { - t.Error("exitTerminalMode() should set focused to false") - } -} - -func TestGetTerminalDir(t *testing.T) { - tests := []struct { - name string - dirMode terminal.DirMode - invocationDir string - expectedDir string - }{ - { - name: "invocation mode returns invocation dir", - dirMode: terminal.DirInvocation, - invocationDir: "/home/user/project", - expectedDir: "/home/user/project", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := terminal.NewManagerWithConfig(terminal.ManagerConfig{ - InvocationDir: tt.invocationDir, - }) - mgr.SetDirMode(tt.dirMode) - m := Model{ - terminalManager: mgr, - } - - got := m.getTerminalDir() - if got != tt.expectedDir { - t.Errorf("getTerminalDir() = %q, want %q", got, tt.expectedDir) - } - }) - } -} diff --git a/internal/tui/ultraplan.go b/internal/tui/ultraplan.go index 54ffcb7..3ed13e9 100644 --- a/internal/tui/ultraplan.go +++ b/internal/tui/ultraplan.go @@ -6,7 +6,6 @@ import ( "github.com/Iron-Ham/claudio/internal/orchestrator" tuimsg "github.com/Iron-Ham/claudio/internal/tui/msg" - "github.com/Iron-Ham/claudio/internal/tui/terminal" "github.com/Iron-Ham/claudio/internal/tui/view" tea "github.com/charmbracelet/bubbletea" ) @@ -77,8 +76,8 @@ func (m *Model) createUltraplanView() *view.UltraplanView { Session: m.session, UltraPlan: m.ultraPlan, ActiveTab: m.activeTab, - Width: m.terminalManager.Width(), - Height: m.terminalManager.Height(), + Width: m.width, + Height: m.height, Outputs: m.outputManager.GetAllOutputs(), GetInstance: func(id string) *orchestrator.Instance { return m.orchestrator.GetInstance(id) @@ -86,14 +85,7 @@ func (m *Model) createUltraplanView() *view.UltraplanView { IsSelected: func(instanceID string) bool { return m.isInstanceSelected(instanceID) }, - InputMode: m.inputMode, - TerminalFocused: m.terminalManager.IsFocused(), - TerminalDirMode: func() string { - if m.terminalManager.DirMode() == terminal.DirWorktree { - return "worktree" - } - return "invoke" - }(), + InputMode: m.inputMode, } return view.NewUltraplanView(ctx) } diff --git a/internal/tui/view/help_bar.go b/internal/tui/view/help_bar.go index 8999669..61151fa 100644 --- a/internal/tui/view/help_bar.go +++ b/internal/tui/view/help_bar.go @@ -19,15 +19,6 @@ type HelpBarState struct { // InputMode indicates whether input forwarding mode is active InputMode bool - // TerminalFocused indicates whether the terminal pane has focus - TerminalFocused bool - - // TerminalVisible indicates whether the terminal pane is visible - TerminalVisible bool - - // TerminalDirMode is the current terminal directory mode ("invoke" or "worktree") - TerminalDirMode string - // ShowDiff indicates whether the diff panel is visible ShowDiff bool @@ -96,7 +87,6 @@ func (v *HelpBarView) renderVerboseCommandHelp(state *HelpBarState) string { line2 := styles.Secondary.Bold(true).Render("View:") + " " + styles.HelpKey.Render("d/diff") + " " + styles.Muted.Render("changes") + " " + styles.HelpKey.Render("m/stats") + " " + styles.Muted.Render("metrics") + " " + - styles.HelpKey.Render("t/term") + " " + styles.Muted.Render("terminal") + " " + styles.HelpKey.Render("h/help") + " " + styles.Muted.Render("full help") + " " + styles.HelpKey.Render("q/quit") + " " + styles.Muted.Render("exit") lines = append(lines, line2) @@ -118,18 +108,6 @@ func (v *HelpBarView) RenderHelp(state *HelpBarState) string { return styles.HelpBar.Render(badge + " " + help) } - if state.TerminalFocused { - badge := styles.ModeBadgeTerminal.Render("TERMINAL") - dirMode := "invoke" - if state.TerminalDirMode == "worktree" { - dirMode = "worktree" - } - help := styles.HelpKey.Render("[Ctrl+]]") + " exit " + - styles.HelpKey.Render("[Ctrl+Shift+T]") + " switch dir " + - styles.Muted.Render("("+dirMode+")") - return styles.HelpBar.Render(badge + " " + help) - } - if state.ShowDiff { badge := styles.ModeBadgeDiff.Render("DIFF") help := styles.HelpKey.Render("[j/k]") + " scroll " + @@ -159,13 +137,6 @@ func (v *HelpBarView) RenderHelp(state *HelpBarState) string { styles.HelpKey.Render("[:q]") + " quit", } - // Add terminal key based on visibility - if state.TerminalVisible { - keys = append(keys, styles.HelpKey.Render("[:t]")+" term "+styles.HelpKey.Render("[`]")+" hide") - } else { - keys = append(keys, styles.HelpKey.Render("[`]")+" term") - } - return styles.HelpBar.Render(badge + " " + strings.Join(keys, " ")) } @@ -180,19 +151,6 @@ func (v *HelpBarView) RenderTripleShotHelp(state *HelpBarState) string { return styles.HelpBar.Render(badge + " " + help) } - // Check for terminal focused mode - if state != nil && state.TerminalFocused { - badge := styles.ModeBadgeTerminal.Render("TERMINAL") - dirMode := "invoke" - if state.TerminalDirMode == "worktree" { - dirMode = "worktree" - } - help := styles.HelpKey.Render("[Ctrl+]]") + " exit " + - styles.HelpKey.Render("[Ctrl+Shift+T]") + " switch dir " + - styles.Muted.Render("("+dirMode+")") - return styles.HelpBar.Render(badge + " " + help) - } - // Normal triple-shot mode badge := styles.ModeBadgeNormal.Render("NORMAL") keys := []string{ diff --git a/internal/tui/view/help_bar_test.go b/internal/tui/view/help_bar_test.go index fcaa2d5..f917e03 100644 --- a/internal/tui/view/help_bar_test.go +++ b/internal/tui/view/help_bar_test.go @@ -72,22 +72,6 @@ func TestRenderHelp(t *testing.T) { }, contains: []string{"INPUT", "Ctrl+]"}, }, - { - name: "terminal focused shows terminal help", - state: &HelpBarState{ - TerminalFocused: true, - TerminalDirMode: "invoke", - }, - contains: []string{"TERMINAL", "Ctrl+]", "invoke"}, - }, - { - name: "terminal focused with worktree mode", - state: &HelpBarState{ - TerminalFocused: true, - TerminalDirMode: "worktree", - }, - contains: []string{"TERMINAL", "worktree"}, - }, { name: "diff view shows diff help", state: &HelpBarState{ @@ -107,13 +91,6 @@ func TestRenderHelp(t *testing.T) { state: &HelpBarState{}, contains: []string{"NORMAL", "cmd", "scroll", "switch", "help", "quit"}, }, - { - name: "terminal visible shows hide option", - state: &HelpBarState{ - TerminalVisible: true, - }, - contains: []string{"hide"}, - }, } for _, tt := range tests { @@ -187,29 +164,6 @@ func TestRenderTripleShotHelp(t *testing.T) { } }) - t.Run("terminal focused shows TERMINAL badge", func(t *testing.T) { - state := &HelpBarState{TerminalFocused: true, TerminalDirMode: "worktree"} - result := RenderTripleShotHelp(state) - - // Should show TERMINAL badge - if !strings.Contains(result, "TERMINAL") { - t.Errorf("expected TERMINAL badge when terminal focused, got: %s", result) - } - // Should show dir mode - if !strings.Contains(result, "worktree") { - t.Errorf("expected worktree dir mode indicator, got: %s", result) - } - }) - - t.Run("input mode takes priority over terminal", func(t *testing.T) { - state := &HelpBarState{InputMode: true, TerminalFocused: true} - result := RenderTripleShotHelp(state) - - // Input mode should take priority - if !strings.Contains(result, "INPUT") { - t.Errorf("expected INPUT badge (takes priority over TERMINAL), got: %s", result) - } - }) } func TestHelpBarView(t *testing.T) { diff --git a/internal/tui/view/mode_indicator.go b/internal/tui/view/mode_indicator.go index 4df3c9e..0f9db7c 100644 --- a/internal/tui/view/mode_indicator.go +++ b/internal/tui/view/mode_indicator.go @@ -16,9 +16,6 @@ type ModeIndicatorState struct { // InputMode indicates input forwarding mode is active InputMode bool - // TerminalFocused indicates the terminal pane has focus - TerminalFocused bool - // AddingTask indicates task input mode is active AddingTask bool } @@ -66,18 +63,6 @@ func (v *ModeIndicatorView) GetModeInfo(state *ModeIndicatorState) *ModeInfo { } } - if state.TerminalFocused { - return &ModeInfo{ - Label: "TERMINAL", - Style: lipgloss.NewStyle(). - Bold(true). - Foreground(styles.TextColor). - Background(styles.SecondaryColor). - Padding(0, 1), - IsHighPriority: true, - } - } - if state.FilterMode { return &ModeInfo{ Label: "FILTER", diff --git a/internal/tui/view/mode_indicator_test.go b/internal/tui/view/mode_indicator_test.go index bb0ea69..f35c2a6 100644 --- a/internal/tui/view/mode_indicator_test.go +++ b/internal/tui/view/mode_indicator_test.go @@ -38,22 +38,6 @@ func TestModeIndicatorView_GetModeInfo_InputMode(t *testing.T) { } } -func TestModeIndicatorView_GetModeInfo_TerminalMode(t *testing.T) { - v := NewModeIndicatorView() - state := &ModeIndicatorState{TerminalFocused: true} - info := v.GetModeInfo(state) - - if info == nil { - t.Fatal("GetModeInfo for terminal mode should not return nil") - } - if info.Label != "TERMINAL" { - t.Errorf("GetModeInfo Label = %q, want %q", info.Label, "TERMINAL") - } - if !info.IsHighPriority { - t.Error("Terminal mode should be high priority") - } -} - func TestModeIndicatorView_GetModeInfo_FilterMode(t *testing.T) { v := NewModeIndicatorView() state := &ModeIndicatorState{FilterMode: true} @@ -110,23 +94,6 @@ func TestModeIndicatorView_GetModeInfo_Priority(t *testing.T) { } } -func TestModeIndicatorView_GetModeInfo_TerminalPriorityOverCommand(t *testing.T) { - // Test that TerminalFocused takes precedence over command - v := NewModeIndicatorView() - state := &ModeIndicatorState{ - TerminalFocused: true, - CommandMode: true, - } - info := v.GetModeInfo(state) - - if info == nil { - t.Fatal("GetModeInfo should not return nil") - } - if info.Label != "TERMINAL" { - t.Errorf("TerminalFocused should take precedence over command, got Label = %q", info.Label) - } -} - func TestModeIndicatorView_Render_NormalMode(t *testing.T) { v := NewModeIndicatorView() state := &ModeIndicatorState{} @@ -190,17 +157,17 @@ func TestRenderModeIndicator(t *testing.T) { } func TestRenderModeIndicatorWithHint(t *testing.T) { - state := &ModeIndicatorState{TerminalFocused: true} + state := &ModeIndicatorState{InputMode: true} result := RenderModeIndicatorWithHint(state) if result == "" { - t.Error("RenderModeIndicatorWithHint should not return empty string for terminal mode") + t.Error("RenderModeIndicatorWithHint should not return empty string for input mode") } - if !strings.Contains(result, "TERMINAL") { - t.Errorf("RenderModeIndicatorWithHint should contain 'TERMINAL', got %q", result) + if !strings.Contains(result, "INPUT") { + t.Errorf("RenderModeIndicatorWithHint should contain 'INPUT', got %q", result) } if !strings.Contains(result, "Ctrl+]") { - t.Errorf("RenderModeIndicatorWithHint should contain exit hint for terminal mode, got %q", result) + t.Errorf("RenderModeIndicatorWithHint should contain exit hint for input mode, got %q", result) } } @@ -222,7 +189,6 @@ func TestModeIndicatorView_AllModes_HaveNonEmptyLabels(t *testing.T) { state *ModeIndicatorState }{ {"InputMode", &ModeIndicatorState{InputMode: true}}, - {"TerminalFocused", &ModeIndicatorState{TerminalFocused: true}}, {"FilterMode", &ModeIndicatorState{FilterMode: true}}, {"CommandMode", &ModeIndicatorState{CommandMode: true}}, {"AddingTask", &ModeIndicatorState{AddingTask: true}}, diff --git a/internal/tui/view/terminal.go b/internal/tui/view/terminal.go deleted file mode 100644 index 30c8ccd..0000000 --- a/internal/tui/view/terminal.go +++ /dev/null @@ -1,169 +0,0 @@ -// Package view provides reusable view components for the TUI. -package view - -import ( - "path/filepath" - "strings" - - "github.com/Iron-Ham/claudio/internal/tui/styles" - "github.com/Iron-Ham/claudio/internal/util" - "github.com/charmbracelet/lipgloss" -) - -// TerminalView handles rendering of the terminal pane at the bottom of the screen. -type TerminalView struct { - Width int - Height int -} - -// NewTerminalView creates a new TerminalView with the given dimensions. -func NewTerminalView(width, height int) *TerminalView { - return &TerminalView{ - Width: width, - Height: height, - } -} - -// TerminalState holds the state needed for rendering the terminal pane. -type TerminalState struct { - // Output is the current terminal output - Output string - // IsWorktreeMode indicates whether we're in worktree mode (true) or project dir mode (false) - IsWorktreeMode bool - // CurrentDir is the current working directory of the terminal - CurrentDir string - // InvocationDir is the directory where Claudio was invoked - InvocationDir string - // TerminalMode indicates if the terminal has input focus - TerminalMode bool - // InstanceID is the active instance ID (for worktree mode display) - InstanceID string -} - -// Render renders the complete terminal pane. -func (v *TerminalView) Render(state TerminalState) string { - if v.Height < 2 { - return "" - } - - var b strings.Builder - - // Header line with directory info and mode indicator - header := v.renderHeader(state) - b.WriteString(header) - b.WriteString("\n") - - // Output area: total height minus border (2 lines) and header (1 line) - // The border style adds 2 lines (top + bottom), and we have 1 header line - outputHeight := v.Height - 3 - if outputHeight < 1 { - outputHeight = 1 - } - output := v.renderOutput(state.Output, outputHeight) - b.WriteString(output) - - // Apply border style - borderStyle := styles.TerminalPaneBorder - if state.TerminalMode { - borderStyle = styles.TerminalPaneBorderFocused - } - - return borderStyle. - Width(v.Width). // Full width - lipgloss Width() sets outer width including border/padding - Height(v.Height). - MaxHeight(v.Height). - Render(b.String()) -} - -// renderHeader renders the terminal pane header line. -func (v *TerminalView) renderHeader(state TerminalState) string { - // Mode indicator - var modeStr string - if state.IsWorktreeMode { - if state.InstanceID != "" { - modeStr = "[worktree:" + state.InstanceID + "]" - } else { - modeStr = "[worktree]" - } - } else { - modeStr = "[project]" - } - - // Shorten the directory path for display - displayDir := state.CurrentDir - if state.InvocationDir != "" && strings.HasPrefix(displayDir, state.InvocationDir) { - relPath, err := filepath.Rel(state.InvocationDir, displayDir) - if err == nil && relPath != "." { - displayDir = "./" + relPath - } else if relPath == "." { - displayDir = "." - } - } - - // Focus indicator - focusIndicator := "" - if state.TerminalMode { - focusIndicator = styles.TerminalFocusIndicator.Render(" TERMINAL ") - } - - // Mode toggle hint - show how to switch to the other mode - var toggleHint string - if state.IsWorktreeMode { - toggleHint = styles.Muted.Render("[:termdir proj]") - } else { - toggleHint = styles.Muted.Render("[:termdir wt]") - } - - // Build header - header := styles.TerminalHeader.Render(modeStr + " " + displayDir) - if focusIndicator != "" { - header = focusIndicator + " " + header - } - - // Calculate available space for hint - maxWidth := v.Width - 4 // Account for borders and padding - headerWidth := lipgloss.Width(header) - hintWidth := lipgloss.Width(toggleHint) - - // Add hint if there's enough space, otherwise truncate if needed - if headerWidth+2+hintWidth <= maxWidth { - gap := maxWidth - headerWidth - hintWidth - header = header + strings.Repeat(" ", gap) + toggleHint - } else if headerWidth > maxWidth { - header = util.TruncateANSI(header, maxWidth) - } - - return header -} - -// renderOutput renders the terminal output area. -func (v *TerminalView) renderOutput(output string, height int) string { - if output == "" { - // Show placeholder text when terminal is empty - placeholder := styles.Muted.Render("(shell ready)") - return placeholder - } - - // Trim trailing whitespace before splitting to prevent capture-pane's trailing - // newline from creating an extra empty element. Without this, when we "take last - // N lines", we could drop content from the beginning (like the shell prompt) while - // keeping empty lines from the end. - output = strings.TrimRight(output, "\r\n") - - lines := strings.Split(output, "\n") - - // Trim trailing empty lines from the end of the content - // (e.g., empty lines before the cursor position in tmux) - for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { - lines = lines[:len(lines)-1] - } - - // Take only the last 'height' lines (most recent output) - // This is done AFTER trimming empty lines so we prioritize showing actual content - if len(lines) > height { - lines = lines[len(lines)-height:] - } - - // Join and return - return strings.Join(lines, "\n") -} diff --git a/internal/tui/view/terminal_test.go b/internal/tui/view/terminal_test.go deleted file mode 100644 index f047d7e..0000000 --- a/internal/tui/view/terminal_test.go +++ /dev/null @@ -1,430 +0,0 @@ -package view - -import ( - "strings" - "testing" -) - -func TestNewTerminalView(t *testing.T) { - tests := []struct { - name string - width int - height int - expectedWidth int - expectedHeight int - }{ - { - name: "standard dimensions", - width: 80, - height: 15, - expectedWidth: 80, - expectedHeight: 15, - }, - { - name: "minimum dimensions", - width: 20, - height: 5, - expectedWidth: 20, - expectedHeight: 5, - }, - { - name: "large dimensions", - width: 200, - height: 50, - expectedWidth: 200, - expectedHeight: 50, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := NewTerminalView(tt.width, tt.height) - if v.Width != tt.expectedWidth { - t.Errorf("Width = %d, want %d", v.Width, tt.expectedWidth) - } - if v.Height != tt.expectedHeight { - t.Errorf("Height = %d, want %d", v.Height, tt.expectedHeight) - } - }) - } -} - -func TestTerminalViewRender(t *testing.T) { - tests := []struct { - name string - width int - height int - state TerminalState - wantContains []string - wantEmpty bool - }{ - { - name: "returns empty for height less than 2", - width: 80, - height: 1, - state: TerminalState{}, - wantEmpty: true, - }, - { - name: "renders project mode header", - width: 80, - height: 15, - state: TerminalState{ - IsWorktreeMode: false, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[project]"}, - }, - { - name: "renders worktree mode header", - width: 80, - height: 15, - state: TerminalState{ - IsWorktreeMode: true, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[worktree]"}, - }, - { - name: "renders worktree mode with instance ID", - width: 80, - height: 15, - state: TerminalState{ - IsWorktreeMode: true, - InstanceID: "abc123", - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[worktree:abc123]"}, - }, - { - name: "renders terminal mode focus indicator", - width: 80, - height: 15, - state: TerminalState{ - TerminalMode: true, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"TERMINAL"}, - }, - { - name: "renders shell ready placeholder when output is empty", - width: 80, - height: 15, - state: TerminalState{ - Output: "", - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"shell ready"}, - }, - { - name: "renders output content", - width: 80, - height: 15, - state: TerminalState{ - Output: "$ ls\nfile1.txt\nfile2.txt", - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"ls", "file1.txt", "file2.txt"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := NewTerminalView(tt.width, tt.height) - result := v.Render(tt.state) - - if tt.wantEmpty { - if result != "" { - t.Errorf("Render() = %q, want empty string", result) - } - return - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("Render() does not contain %q", want) - } - } - }) - } -} - -func TestTerminalViewRenderOutput(t *testing.T) { - tests := []struct { - name string - output string - height int - wantContains []string - wantExcludes []string - }{ - { - name: "empty output shows placeholder", - output: "", - height: 10, - wantContains: []string{"shell ready"}, - }, - { - name: "single line output", - output: "hello world", - height: 10, - wantContains: []string{"hello world"}, - }, - { - name: "multiline output within height", - output: "line1\nline2\nline3", - height: 10, - wantContains: []string{"line1", "line2", "line3"}, - }, - { - name: "output exceeds height - shows only last lines", - output: "line1\nline2\nline3\nline4\nline5", - height: 3, - wantContains: []string{"line3", "line4", "line5"}, - wantExcludes: []string{"line1", "line2"}, - }, - { - name: "trims trailing empty lines", - output: "line1\nline2\n\n\n", - height: 10, - wantContains: []string{"line1", "line2"}, - }, - { - name: "preserves first line when capture ends with newline", - output: "PROMPT\n\n\n\n\n\n\n\n\n\n\n\n", - height: 12, - wantContains: []string{"PROMPT"}, - }, - { - name: "preserves prompt with ANSI codes when capture ends with newline", - output: "\x1b[32mPrompt ❯\x1b[0m\n\n\n\n\n\n\n\n\n\n\n\n", - height: 12, - wantContains: []string{"Prompt"}, - }, - { - name: "preserves prompt with content and trailing newlines", - output: "PROMPT ❯ ls\nfile1.txt\nfile2.txt\n\n\n", - height: 10, - wantContains: []string{"PROMPT", "file1.txt", "file2.txt"}, - }, - { - name: "preserves content when lines equal height after trim", - output: "line1\nline2\nline3\n", - height: 3, - wantContains: []string{"line1", "line2", "line3"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := NewTerminalView(80, 15) - result := v.renderOutput(tt.output, tt.height) - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("renderOutput() does not contain %q, got %q", want, result) - } - } - - for _, exclude := range tt.wantExcludes { - if strings.Contains(result, exclude) { - t.Errorf("renderOutput() should not contain %q, got %q", exclude, result) - } - } - }) - } -} - -func TestTerminalViewRenderHeader(t *testing.T) { - tests := []struct { - name string - width int - state TerminalState - wantContains []string - }{ - { - name: "project mode", - width: 80, - state: TerminalState{ - IsWorktreeMode: false, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[project]"}, - }, - { - name: "worktree mode without instance", - width: 80, - state: TerminalState{ - IsWorktreeMode: true, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[worktree]"}, - }, - { - name: "worktree mode with instance", - width: 80, - state: TerminalState{ - IsWorktreeMode: true, - InstanceID: "test-id", - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[worktree:test-id]"}, - }, - { - name: "shows relative path", - width: 80, - state: TerminalState{ - IsWorktreeMode: false, - CurrentDir: "/home/user/project/subdir", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"./subdir"}, - }, - { - name: "shows dot for same directory", - width: 80, - state: TerminalState{ - IsWorktreeMode: false, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"."}, - }, - { - name: "terminal mode shows focus indicator", - width: 80, - state: TerminalState{ - TerminalMode: true, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"TERMINAL"}, - }, - { - name: "project mode shows worktree toggle hint", - width: 80, - state: TerminalState{ - IsWorktreeMode: false, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[project]", ":termdir wt"}, - }, - { - name: "worktree mode shows project toggle hint", - width: 80, - state: TerminalState{ - IsWorktreeMode: true, - CurrentDir: "/home/user/project", - InvocationDir: "/home/user/project", - }, - wantContains: []string{"[worktree]", ":termdir proj"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := NewTerminalView(tt.width, 15) - result := v.renderHeader(tt.state) - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("renderHeader() does not contain %q, got %q", want, result) - } - } - }) - } -} - -// TruncateANSI tests are now in internal/util/strings_test.go - -func TestOutputHeightCalculation(t *testing.T) { - // This test verifies the output height calculation accounts for border and header - // The formula should be: outputHeight = totalHeight - 3 (2 for border, 1 for header) - tests := []struct { - name string - totalHeight int - expectedLineCount int - outputLines int - expectTruncation bool - }{ - { - name: "height 15 gives 12 output lines", - totalHeight: 15, - expectedLineCount: 12, // 15 - 3 = 12 - outputLines: 12, - expectTruncation: false, - }, - { - name: "height 10 gives 7 output lines", - totalHeight: 10, - expectedLineCount: 7, // 10 - 3 = 7 - outputLines: 7, - expectTruncation: false, - }, - { - name: "height 5 gives 2 output lines", - totalHeight: 5, - expectedLineCount: 2, // 5 - 3 = 2 - outputLines: 5, - expectTruncation: true, - }, - { - name: "minimum height 3 gives 1 output line (clamped)", - totalHeight: 3, - expectedLineCount: 1, // max(3 - 3, 1) = 1 - outputLines: 3, - expectTruncation: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Generate output with the specified number of lines - lines := make([]string, tt.outputLines) - for i := range lines { - lines[i] = "line" - } - output := strings.Join(lines, "\n") - - v := NewTerminalView(80, tt.totalHeight) - - // Calculate expected output height - outputHeight := tt.totalHeight - 3 - if outputHeight < 1 { - outputHeight = 1 - } - - if outputHeight != tt.expectedLineCount { - t.Errorf("expected output height %d, got %d", tt.expectedLineCount, outputHeight) - } - - // Verify the render output respects the height limit - result := v.renderOutput(output, outputHeight) - resultLines := strings.Split(result, "\n") - - // Trim empty lines from the result - for len(resultLines) > 0 && strings.TrimSpace(resultLines[len(resultLines)-1]) == "" { - resultLines = resultLines[:len(resultLines)-1] - } - - if tt.expectTruncation { - if len(resultLines) > tt.expectedLineCount { - t.Errorf("output has %d lines, expected at most %d", len(resultLines), tt.expectedLineCount) - } - } else { - if len(resultLines) != tt.expectedLineCount { - t.Errorf("output has %d lines, expected %d", len(resultLines), tt.expectedLineCount) - } - } - }) - } -} diff --git a/internal/tui/view/ultraplan/context.go b/internal/tui/view/ultraplan/context.go index 87fa271..8663d80 100644 --- a/internal/tui/view/ultraplan/context.go +++ b/internal/tui/view/ultraplan/context.go @@ -21,12 +21,6 @@ type RenderContext struct { // InputMode indicates whether input forwarding mode is active. // Used by help bar rendering to show appropriate mode badge. InputMode bool - - // TerminalFocused indicates whether the terminal pane has focus. - TerminalFocused bool - - // TerminalDirMode is the current terminal directory mode ("invoke" or "worktree"). - TerminalDirMode string } // State holds ultra-plan specific UI state. diff --git a/internal/tui/view/ultraplan/help.go b/internal/tui/view/ultraplan/help.go index 0eb0c11..1a64db4 100644 --- a/internal/tui/view/ultraplan/help.go +++ b/internal/tui/view/ultraplan/help.go @@ -28,19 +28,6 @@ func (h *HelpRenderer) Render() string { return styles.HelpBar.Width(h.ctx.Width).Render(badge + " " + help) } - // Terminal focused mode - shows TERMINAL badge - if h.ctx.TerminalFocused { - badge := styles.ModeBadgeTerminal.Render("TERMINAL") - dirMode := "invoke" - if h.ctx.TerminalDirMode == "worktree" { - dirMode = "worktree" - } - help := styles.HelpKey.Render("[Ctrl+]]") + " exit " + - styles.HelpKey.Render("[Ctrl+Shift+T]") + " switch dir " + - styles.Muted.Render("("+dirMode+")") - return styles.HelpBar.Width(h.ctx.Width).Render(badge + " " + help) - } - if h.ctx.UltraPlan == nil || h.ctx.UltraPlan.Coordinator == nil { return "" }