diff --git a/cmd/dj/main.go b/cmd/dj/main.go index 17039cb..89d58e7 100644 --- a/cmd/dj/main.go +++ b/cmd/dj/main.go @@ -48,7 +48,7 @@ func runApp(cmd *cobra.Command, args []string) error { tui.WithInteractiveCommand(cfg.Interactive.Command, cfg.Interactive.Args...), ) - program := tea.NewProgram(app, tea.WithAltScreen()) + program := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) finalModel, err := program.Run() if finalApp, ok := finalModel.(tui.AppModel); ok { diff --git a/docs/plans/2026-03-18-session-scrollback-design.md b/docs/plans/2026-03-18-session-scrollback-design.md new file mode 100644 index 0000000..969d6fd --- /dev/null +++ b/docs/plans/2026-03-18-session-scrollback-design.md @@ -0,0 +1,60 @@ +# Session Scrollback Design + +## Problem + +When a codex CLI session produces output that scrolls past the visible area, the user cannot scroll up to review earlier output. The vt emulator stores a scrollback buffer, but the TUI does not expose it. + +## Approach + +Add scroll state directly to `PTYSession`. The emulator already maintains a scrollback buffer via `Scrollback()`. When the user scrolls up with the mouse wheel, the `Render()` method builds a custom viewport from scrollback lines + visible screen lines instead of calling `emulator.Render()`. + +## Design + +### Scroll state on PTYSession + +Add to `PTYSession`: +- `scrollOffset int` — 0 means at bottom (live), positive means lines scrolled up +- `ScrollUp(lines int)` — increase offset, clamped to max scrollback +- `ScrollDown(lines int)` — decrease offset, clamped to 0 +- `ScrollToBottom()` — reset offset to 0 +- `IsScrolledUp() bool` — returns `scrollOffset > 0` +- `ScrollOffset() int` — returns current offset + +### Custom viewport rendering + +When `scrollOffset > 0`, `Render()` builds output by: +1. Collecting all scrollback lines via `Scrollback().Lines()` +2. Collecting visible screen lines via `CellAt(x, y)` for each row +3. Concatenating into one logical buffer (scrollback on top, screen on bottom) +4. Slicing a window of `emulator.Height()` lines, offset from the bottom by `scrollOffset` +5. Converting cells to styled strings for display + +When `scrollOffset == 0`, `Render()` calls `emulator.Render()` as before. + +### Mouse input + +- Enable mouse mode with `tea.WithMouseCellMotion()` in program options +- Handle `tea.MouseMsg` in `Update()`: + - Scroll wheel up → `ScrollUp` on active PTY session + - Scroll wheel down → `ScrollDown` on active PTY session +- Do not forward scroll wheel events to the PTY process +- Non-scroll mouse events are not forwarded (PTY apps that need mouse input are out of scope) + +### Auto-scroll behavior + +When new output arrives while scrolled up, the view stays in place. The user must scroll down manually or the offset resets on keyboard input to the PTY. + +### Scroll indicator + +When `IsScrolledUp()` is true, render a bottom-line indicator in the session pane: +- Format: `↓ N lines below` +- Styled with a distinct background so it overlays the content visibly +- Disappears when scroll offset returns to 0 + +## Files changed + +- `internal/tui/pty_session.go` — scroll state, modified `Render()` +- `internal/tui/app.go` — mouse message handling in `Update()` +- `internal/tui/app_pty.go` — scroll dispatch for active session +- `internal/tui/app_view.go` — scroll indicator overlay in `renderPTYContent()` +- `cmd/dj/main.go` — add `tea.WithMouseCellMotion()` to program options diff --git a/docs/plans/2026-03-18-session-scrollback-plan.md b/docs/plans/2026-03-18-session-scrollback-plan.md new file mode 100644 index 0000000..f8587f7 --- /dev/null +++ b/docs/plans/2026-03-18-session-scrollback-plan.md @@ -0,0 +1,683 @@ +# Session Scrollback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable mouse-wheel scrollback on PTY session panels so users can scroll up through codex CLI output history. + +**Architecture:** Add scroll offset tracking to `PTYSession`, build a custom viewport renderer that combines scrollback buffer lines with visible screen lines, intercept mouse wheel events in Bubble Tea's `Update()` to adjust the offset, and overlay a scroll indicator when scrolled up. + +**Tech Stack:** Go, Bubble Tea (mouse events), charmbracelet/x/vt (scrollback buffer, `uv.Line.Render()`), Lipgloss (indicator styling) + +--- + +### Task 1: Add scroll state to PTYSession + +**Files:** +- Modify: `internal/tui/pty_session.go` +- Test: `internal/tui/pty_session_test.go` + +**Step 1: Write failing tests for scroll state methods** + +Add to `pty_session_test.go`: + +```go +func TestPTYSessionScrollOffset(t *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: "t-1", + Command: "echo", + Args: []string{"test"}, + SendMsg: func(msg PTYOutputMsg) {}, + }) + + if session.ScrollOffset() != 0 { + t.Errorf("expected initial offset 0, got %d", session.ScrollOffset()) + } + + if session.IsScrolledUp() { + t.Error("expected not scrolled up initially") + } +} + +func TestPTYSessionScrollUpDown(t *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: "t-1", + Command: "echo", + Args: []string{"test"}, + SendMsg: func(msg PTYOutputMsg) {}, + }) + + session.ScrollUp(5) + if session.ScrollOffset() != 0 { + t.Errorf("expected offset 0 with no scrollback, got %d", session.ScrollOffset()) + } + + session.ScrollDown(3) + if session.ScrollOffset() != 0 { + t.Errorf("expected offset 0 after scroll down, got %d", session.ScrollOffset()) + } +} + +func TestPTYSessionScrollToBottom(t *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: "t-1", + Command: "echo", + Args: []string{"test"}, + SendMsg: func(msg PTYOutputMsg) {}, + }) + + session.ScrollToBottom() + if session.ScrollOffset() != 0 { + t.Errorf("expected offset 0 after scroll to bottom, got %d", session.ScrollOffset()) + } + if session.IsScrolledUp() { + t.Error("expected not scrolled up after scroll to bottom") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestPTYSessionScroll -v` +Expected: FAIL — `ScrollOffset`, `IsScrolledUp`, `ScrollUp`, `ScrollDown`, `ScrollToBottom` undefined + +**Step 3: Implement scroll state on PTYSession** + +Add to `pty_session.go`: + +1. Add `scrollOffset int` field to the `PTYSession` struct. + +2. Add these methods: + +```go +const scrollStep = 3 + +func (ps *PTYSession) ScrollUp(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + maxOffset := ps.emulator.ScrollbackLen() + ps.scrollOffset += lines + if ps.scrollOffset > maxOffset { + ps.scrollOffset = maxOffset + } +} + +func (ps *PTYSession) ScrollDown(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset -= lines + if ps.scrollOffset < 0 { + ps.scrollOffset = 0 + } +} + +func (ps *PTYSession) ScrollToBottom() { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset = 0 +} + +func (ps *PTYSession) ScrollOffset() int { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset +} + +func (ps *PTYSession) IsScrolledUp() bool { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset > 0 +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestPTYSessionScroll -v` +Expected: PASS + +**Step 5: Commit** + +``` +git add internal/tui/pty_session.go internal/tui/pty_session_test.go +git commit -m "feat: add scroll state tracking to PTYSession" +``` + +--- + +### Task 2: Implement custom viewport rendering when scrolled + +**Files:** +- Create: `internal/tui/pty_scroll.go` +- Test: `internal/tui/pty_scroll_test.go` +- Modify: `internal/tui/pty_session.go` (update `Render()`) + +**Step 1: Write failing test for scrolled rendering** + +Create `internal/tui/pty_scroll_test.go`: + +```go +package tui + +import ( + "strings" + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +func TestRenderScrolledViewport(t *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(10), + uv.NewLine(10), + } + screenLines := []string{"visible-1", "visible-2", "visible-3"} + + result := renderScrolledViewport(scrollbackLines, screenLines, 3, 1) + + if len(result) != 3 { + t.Fatalf("expected 3 lines, got %d", len(result)) + } + + if !strings.Contains(result[2], "visible-2") { + t.Errorf("expected visible-2 at bottom, got %q", result[2]) + } +} + +func TestRenderScrolledViewportAtMaxOffset(t *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(10), + uv.NewLine(10), + } + screenLines := []string{"vis-1", "vis-2"} + + result := renderScrolledViewport(scrollbackLines, screenLines, 2, 4) + + if len(result) != 2 { + t.Fatalf("expected 2 lines, got %d", len(result)) + } +} + +func TestRenderScrolledViewportZeroOffset(t *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(10), + } + screenLines := []string{"vis-1", "vis-2"} + + result := renderScrolledViewport(scrollbackLines, screenLines, 2, 0) + + if len(result) != 2 { + t.Fatalf("expected 2 lines, got %d", len(result)) + } + if result[0] != "vis-1" { + t.Errorf("expected vis-1, got %q", result[0]) + } + if result[1] != "vis-2" { + t.Errorf("expected vis-2, got %q", result[1]) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestRenderScrolledViewport -v` +Expected: FAIL — `renderScrolledViewport` undefined + +**Step 3: Implement the scrolled viewport renderer** + +Create `internal/tui/pty_scroll.go`: + +```go +package tui + +import ( + "strings" + + uv "github.com/charmbracelet/ultraviolet" +) + +func renderScrolledViewport( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) []string { + allLines := make([]string, 0, len(scrollbackLines)+len(screenLines)) + + for _, line := range scrollbackLines { + allLines = append(allLines, line.Render()) + } + allLines = append(allLines, screenLines...) + + totalLines := len(allLines) + end := totalLines - scrollOffset + if end < 0 { + end = 0 + } + start := end - viewportHeight + if start < 0 { + start = 0 + } + if end > totalLines { + end = totalLines + } + + visible := allLines[start:end] + + for len(visible) < viewportHeight { + visible = append([]string{""}, visible...) + } + + return visible +} + +func renderScrolledOutput( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) string { + lines := renderScrolledViewport(scrollbackLines, screenLines, viewportHeight, scrollOffset) + return strings.Join(lines, "\n") +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestRenderScrolledViewport -v` +Expected: PASS + +**Step 5: Update PTYSession.Render() to use scrolled viewport** + +Modify `Render()` in `pty_session.go`: + +```go +func (ps *PTYSession) Render() string { + ps.mu.Lock() + offset := ps.scrollOffset + ps.mu.Unlock() + + if offset == 0 { + return ps.emulator.Render() + } + + scrollback := ps.emulator.Scrollback() + scrollbackLen := scrollback.Len() + scrollbackLines := make([]uv.Line, scrollbackLen) + for i := 0; i < scrollbackLen; i++ { + scrollbackLines[i] = scrollback.Line(i) + } + + screenContent := ps.emulator.Render() + screenLines := strings.Split(screenContent, "\n") + + return renderScrolledOutput( + scrollbackLines, + screenLines, + ps.emulator.Height(), + offset, + ) +} +``` + +Add `"strings"` and `uv "github.com/charmbracelet/ultraviolet"` to imports in `pty_session.go`. + +**Step 6: Run all PTY tests** + +Run: `go test ./internal/tui -run TestPTYSession -v` +Expected: PASS + +**Step 7: Commit** + +``` +git add internal/tui/pty_scroll.go internal/tui/pty_scroll_test.go internal/tui/pty_session.go +git commit -m "feat: custom viewport rendering for scrolled PTY sessions" +``` + +--- + +### Task 3: Enable mouse events and handle scroll wheel + +**Files:** +- Modify: `cmd/dj/main.go` +- Modify: `internal/tui/app.go` +- Modify: `internal/tui/app_pty.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write failing test for mouse scroll handling** + +Add to `app_test.go`: + +```go +func TestAppMouseScrollUpOnSession(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollUp) + app = updated.(AppModel) + + ptySession := app.ptySessions["t-1"] + offset := ptySession.ScrollOffset() + if offset < 0 { + t.Errorf("expected non-negative scroll offset, got %d", offset) + } +} + +func TestAppMouseScrollDownOnSession(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions["t-1"] + ptySession.ScrollUp(10) + + scrollDown := tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollDown) + app = updated.(AppModel) + + offset := ptySession.ScrollOffset() + expectedMax := 10 - scrollStep + if offset > expectedMax { + t.Errorf("expected offset <= %d after scroll down, got %d", expectedMax, offset) + } +} + +func TestAppMouseScrollIgnoredOnCanvas(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store) + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ := app.Update(scrollUp) + _ = updated.(AppModel) +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestAppMouseScroll -v` +Expected: FAIL — `tea.MouseMsg` not handled in `Update()` + +**Step 3: Add mouse event handling to Update** + +In `app.go`, add a `tea.MouseMsg` case to `Update()`: + +```go +case tea.MouseMsg: + return app.handleMouse(msg) +``` + +Add the handler method to `app_pty.go`: + +```go +func (app AppModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + isScrollWheel := msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown + if !isScrollWheel { + return app, nil + } + + if app.focusPane != FocusPaneSession { + return app, nil + } + + activeID := app.sessionPanel.ActiveThreadID() + if activeID == "" { + return app, nil + } + + ptySession, exists := app.ptySessions[activeID] + if !exists { + return app, nil + } + + if msg.Button == tea.MouseButtonWheelUp { + ptySession.ScrollUp(scrollStep) + } else { + ptySession.ScrollDown(scrollStep) + } + + return app, nil +} +``` + +**Step 4: Enable mouse in main.go** + +In `cmd/dj/main.go`, change: + +```go +program := tea.NewProgram(app, tea.WithAltScreen()) +``` + +to: + +```go +program := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) +``` + +**Step 5: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestAppMouseScroll -v` +Expected: PASS + +**Step 6: Run all tests** + +Run: `go test ./internal/tui -v` +Expected: PASS + +**Step 7: Commit** + +``` +git add cmd/dj/main.go internal/tui/app.go internal/tui/app_pty.go internal/tui/app_test.go +git commit -m "feat: mouse wheel scroll for PTY session panels" +``` + +--- + +### Task 4: Add scroll indicator overlay + +**Files:** +- Modify: `internal/tui/app_view.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write failing test for scroll indicator** + +Add to `app_test.go`: + +```go +func TestAppViewShowsScrollIndicator(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 80 + app.height = 30 + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions["t-1"] + ptySession.ScrollUp(5) + + view := app.View() + hasIndicator := strings.Contains(view, "↓") || strings.Contains(view, "lines below") + if !hasIndicator { + t.Error("expected scroll indicator when scrolled up") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestAppViewShowsScrollIndicator -v` +Expected: FAIL — no indicator in view output + +**Step 3: Add scroll indicator to renderPTYContent** + +In `app_view.go`, modify `renderPTYContent`: + +```go +const scrollIndicatorStyle = "240" + +func (app AppModel) renderPTYContent(threadID string) string { + ptySession, exists := app.ptySessions[threadID] + if !exists { + return "" + } + + content := ptySession.Render() + hasVisibleContent := strings.TrimSpace(content) != "" + if !hasVisibleContent && !ptySession.Running() { + return fmt.Sprintf("[process exited: %d]", ptySession.ExitCode()) + } + + if ptySession.IsScrolledUp() { + indicator := renderScrollIndicator(ptySession.ScrollOffset()) + lines := strings.Split(content, "\n") + if len(lines) > 0 { + lines[len(lines)-1] = indicator + } + content = strings.Join(lines, "\n") + } + + return content +} + +func renderScrollIndicator(linesBelow int) string { + text := fmt.Sprintf(" ↓ %d lines below ", linesBelow) + style := lipgloss.NewStyle(). + Background(lipgloss.Color(scrollIndicatorStyle)). + Foreground(lipgloss.Color("255")) + return style.Render(text) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui -run TestAppViewShowsScrollIndicator -v` +Expected: PASS + +**Step 5: Run all tests** + +Run: `go test ./internal/tui -v` +Expected: PASS + +**Step 6: Commit** + +``` +git add internal/tui/app_view.go internal/tui/app_test.go +git commit -m "feat: scroll indicator overlay when session is scrolled up" +``` + +--- + +### Task 5: Update help screen with scroll keybinding + +**Files:** +- Modify: `internal/tui/help.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write failing test** + +Add to `app_test.go`: + +```go +func TestHelpShowsScrollKeybinding(t *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Scroll") { + t.Error("expected Scroll keybinding in help") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestHelpShowsScrollKeybinding -v` +Expected: FAIL + +**Step 3: Add scroll entry to help** + +Read `internal/tui/help.go` and add a line for mouse scroll in the keybindings list. The exact format depends on the existing help entries — match their pattern. Add something like: + +``` +"Mouse Wheel Scroll session up/down" +``` + +in the session section of the help text. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui -run TestHelpShowsScrollKeybinding -v` +Expected: PASS + +**Step 5: Commit** + +``` +git add internal/tui/help.go internal/tui/app_test.go +git commit -m "feat: add scroll keybinding to help screen" +``` + +--- + +### Task 6: Lint and full test pass + +**Files:** All modified files + +**Step 1: Run linter** + +Run: `golangci-lint run` +Expected: No errors. If there are funlen violations (60 line max), extract helper functions. + +**Step 2: Run all tests with race detector** + +Run: `go test ./... -v -race` +Expected: PASS + +**Step 3: Run build** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 4: Fix any issues found** + +Address lint/race/build errors if any. + +**Step 5: Commit fixes if needed** + +``` +git add -A +git commit -m "fix: lint and race detector issues" +``` diff --git a/internal/tui/app.go b/internal/tui/app.go index 0c1f8e7..1f3a97e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -105,6 +105,8 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return app.handleKey(msg) + case tea.MouseMsg: + return app.handleMouse(msg) case tea.WindowSizeMsg: return app.handleWindowSize(msg) case protoEventMsg: diff --git a/internal/tui/app_mouse_test.go b/internal/tui/app_mouse_test.go new file mode 100644 index 0000000..3f06af3 --- /dev/null +++ b/internal/tui/app_mouse_test.go @@ -0,0 +1,86 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +const ( + mouseTestWidth = 120 + mouseTestHeight = 40 + mouseTestThread = "t-1" + mouseTestTitle = "Thread 1" + mouseTestCmd = "cat" +) + +func TestAppMouseScrollUpOnSession(testing *testing.T) { + store := state.NewThreadStore() + store.Add(mouseTestThread, mouseTestTitle) + + app := NewAppModel(store, WithInteractiveCommand(mouseTestCmd)) + app.width = mouseTestWidth + app.height = mouseTestHeight + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollUp) + app = updated.(AppModel) + + ptySession := app.ptySessions[mouseTestThread] + offset := ptySession.ScrollOffset() + if offset < 0 { + testing.Errorf("expected non-negative scroll offset, got %d", offset) + } +} + +func TestAppMouseScrollDownOnSession(testing *testing.T) { + store := state.NewThreadStore() + store.Add(mouseTestThread, mouseTestTitle) + + app := NewAppModel(store, WithInteractiveCommand(mouseTestCmd)) + app.width = mouseTestWidth + app.height = mouseTestHeight + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions[mouseTestThread] + ptySession.ScrollUp(scrollStep) + + scrollDown := tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollDown) + app = updated.(AppModel) + + offset := ptySession.ScrollOffset() + if offset != 0 { + testing.Errorf("expected offset 0 after scroll down, got %d", offset) + } +} + +func TestAppMouseScrollIgnoredOnCanvas(testing *testing.T) { + store := state.NewThreadStore() + store.Add(mouseTestThread, mouseTestTitle) + + app := NewAppModel(store) + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ := app.Update(scrollUp) + _ = updated.(AppModel) +} diff --git a/internal/tui/app_pty.go b/internal/tui/app_pty.go index cc1bede..ab04c85 100644 --- a/internal/tui/app_pty.go +++ b/internal/tui/app_pty.go @@ -2,6 +2,35 @@ package tui import tea "github.com/charmbracelet/bubbletea" +func (app AppModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + isScrollWheel := msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown + if !isScrollWheel { + return app, nil + } + + if app.focusPane != FocusPaneSession { + return app, nil + } + + activeID := app.sessionPanel.ActiveThreadID() + if activeID == "" { + return app, nil + } + + ptySession, exists := app.ptySessions[activeID] + if !exists { + return app, nil + } + + if msg.Button == tea.MouseButtonWheelUp { + ptySession.ScrollUp(scrollStep) + } else { + ptySession.ScrollDown(scrollStep) + } + + return app, nil +} + func (app AppModel) handleSessionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: @@ -70,25 +99,31 @@ func (app AppModel) pinSession(threadID string) (tea.Model, tea.Cmd) { return app, nil } - _, hasPTY := app.ptySessions[threadID] - if !hasPTY { - ptySession := NewPTYSession(PTYSessionConfig{ - ThreadID: threadID, - Command: app.resolveInteractiveCmd(), - Args: app.interactiveArgs, - SendMsg: app.ptyEventCallback(), - }) - if err := ptySession.Start(); err != nil { - app.statusBar.SetError(err.Error()) - return app, nil - } - app.ptySessions[threadID] = ptySession - } + app.ensurePTYSession(threadID) app.sessionPanel.Pin(threadID) return app, app.rebalancePTYSizes() } +func (app *AppModel) ensurePTYSession(threadID string) { + _, hasPTY := app.ptySessions[threadID] + if hasPTY { + return + } + + ptySession := NewPTYSession(PTYSessionConfig{ + ThreadID: threadID, + Command: app.resolveInteractiveCmd(), + Args: app.interactiveArgs, + SendMsg: app.ptyEventCallback(), + }) + if err := ptySession.Start(); err != nil { + app.statusBar.SetError(err.Error()) + return + } + app.ptySessions[threadID] = ptySession +} + func (app AppModel) pinnedIndex(threadID string) int { for index, pinned := range app.sessionPanel.PinnedSessions() { if pinned == threadID { @@ -141,6 +176,18 @@ func (app AppModel) rebalancePTYSizes() tea.Cmd { return nil } + contentWidth, contentHeight := app.panelContentDimensions() + + if app.sessionPanel.Zoomed() { + app.resizeZoomedSession(contentWidth, contentHeight) + return nil + } + + app.resizeSplitSessions(pinned, contentWidth, contentHeight) + return nil +} + +func (app AppModel) panelContentDimensions() (int, int) { canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) panelHeight := app.height - canvasHeight - dividerHeight if panelHeight < 1 { @@ -155,16 +202,19 @@ func (app AppModel) rebalancePTYSizes() tea.Cmd { if contentHeight < 1 { contentHeight = 1 } + return contentWidth, contentHeight +} - if app.sessionPanel.Zoomed() { - activeID := app.sessionPanel.ActiveThreadID() - ptySession, exists := app.ptySessions[activeID] - if exists { - ptySession.Resize(contentWidth, contentHeight) - } - return nil +func (app AppModel) resizeZoomedSession(contentWidth int, contentHeight int) { + activeID := app.sessionPanel.ActiveThreadID() + ptySession, exists := app.ptySessions[activeID] + if !exists { + return } + ptySession.Resize(contentWidth, contentHeight) +} +func (app AppModel) resizeSplitSessions(pinned []string, contentWidth int, contentHeight int) { count := len(pinned) paneWidth := app.width/count - sessionPaneBorderSize if paneWidth < 1 { @@ -172,11 +222,11 @@ func (app AppModel) rebalancePTYSizes() tea.Cmd { } for _, threadID := range pinned { ptySession, exists := app.ptySessions[threadID] - if exists { - ptySession.Resize(paneWidth, contentHeight) + if !exists { + continue } + ptySession.Resize(paneWidth, contentHeight) } - return nil } func (app *AppModel) StopAllPTYSessions() { diff --git a/internal/tui/app_scroll_indicator_test.go b/internal/tui/app_scroll_indicator_test.go new file mode 100644 index 0000000..8c163c5 --- /dev/null +++ b/internal/tui/app_scroll_indicator_test.go @@ -0,0 +1,52 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +const ( + indicatorTestWidth = 80 + indicatorTestHeight = 30 + indicatorScrollUp = 5 + indicatorTestThread = "t-1" + indicatorScrollbackLines = 30 +) + +func TestAppViewShowsScrollIndicator(testing *testing.T) { + store := state.NewThreadStore() + store.Add(indicatorTestThread, "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = indicatorTestWidth + app.height = indicatorTestHeight + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions[indicatorTestThread] + fillScrollback(ptySession) + + ptySession.ScrollUp(indicatorScrollUp) + + view := app.View() + hasDownArrow := strings.Contains(view, "↓") + hasLinesBelow := strings.Contains(view, "lines below") + hasIndicator := hasDownArrow || hasLinesBelow + if !hasIndicator { + testing.Error("expected scroll indicator when scrolled up") + } +} + +func fillScrollback(session *PTYSession) { + for index := 0; index < indicatorScrollbackLines; index++ { + line := fmt.Sprintf("scrollback line %d\r\n", index) + session.emulator.Write([]byte(line)) + } +} diff --git a/internal/tui/app_view.go b/internal/tui/app_view.go index 2d279fd..f077176 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -8,9 +8,11 @@ import ( ) const ( - headerHeight = 1 - statusBarHeight = 1 - viewSeparator = "\n" + headerHeight = 1 + statusBarHeight = 1 + viewSeparator = "\n" + colorDimGray = "240" + scrollIndicatorFg = "255" ) func joinSections(sections ...string) string { @@ -130,18 +132,37 @@ func (app AppModel) renderPTYContent(threadID string) string { content := ptySession.Render() hasVisibleContent := strings.TrimSpace(content) != "" - if hasVisibleContent { - return content + isEmptyAndExited := !hasVisibleContent && !ptySession.Running() + if isEmptyAndExited { + return fmt.Sprintf("[process exited: %d]", ptySession.ExitCode()) } - if !ptySession.Running() { - return fmt.Sprintf("[process exited: %d]", ptySession.ExitCode()) + if ptySession.IsScrolledUp() { + content = overlayScrollIndicator(content, ptySession.ScrollOffset()) } + return content } +func overlayScrollIndicator(content string, linesBelow int) string { + indicator := renderScrollIndicator(linesBelow) + lines := strings.Split(content, viewSeparator) + if len(lines) > 0 { + lines[len(lines)-1] = indicator + } + return strings.Join(lines, viewSeparator) +} + +func renderScrollIndicator(linesBelow int) string { + text := fmt.Sprintf(" ↓ %d lines below ", linesBelow) + style := lipgloss.NewStyle(). + Background(lipgloss.Color(colorDimGray)). + Foreground(lipgloss.Color(scrollIndicatorFg)) + return style.Render(text) +} + func (app AppModel) sessionPaneStyle(width int, height int, active bool) lipgloss.Style { - borderColor := lipgloss.Color("240") + borderColor := lipgloss.Color(colorDimGray) if active { borderColor = lipgloss.Color("39") } diff --git a/internal/tui/help.go b/internal/tui/help.go index 5c3244e..a57c0b3 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -7,16 +7,21 @@ import ( "github.com/charmbracelet/lipgloss" ) -const helpKeyColumnWidth = 12 +const ( + helpKeyColumnWidth = 12 + helpAccentColor = "39" + helpPaddingH = 2 + helpNewline = "\n" +) var ( helpBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("39")). - Padding(1, 2) + BorderForeground(lipgloss.Color(helpAccentColor)). + Padding(1, helpPaddingH) helpTitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("39")). + Foreground(lipgloss.Color(helpAccentColor)). MarginBottom(1) helpKeyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("42")). @@ -45,6 +50,7 @@ var keybindings = []keybinding{ {"Ctrl+B 1-9", "Jump to session pane"}, {"Ctrl+B x", "Unpin focused session"}, {"Ctrl+B z", "Toggle zoom session"}, + {"Mouse Wheel", "Scroll session up/down"}, {"?", "Toggle help"}, {"Ctrl+C", "Quit"}, } @@ -65,6 +71,6 @@ func (help HelpModel) View() string { lines = append(lines, fmt.Sprintf("%s %s", key, desc)) } - content := title + "\n" + strings.Join(lines, "\n") + content := title + helpNewline + strings.Join(lines, helpNewline) return helpBorderStyle.Render(content) } diff --git a/internal/tui/help_scroll_test.go b/internal/tui/help_scroll_test.go new file mode 100644 index 0000000..bf0c5b8 --- /dev/null +++ b/internal/tui/help_scroll_test.go @@ -0,0 +1,14 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestHelpShowsScrollKeybinding(testing *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Scroll") { + testing.Error("expected Scroll keybinding in help") + } +} diff --git a/internal/tui/pty_scroll.go b/internal/tui/pty_scroll.go new file mode 100644 index 0000000..3e6e1b4 --- /dev/null +++ b/internal/tui/pty_scroll.go @@ -0,0 +1,52 @@ +package tui + +import ( + "strings" + + uv "github.com/charmbracelet/ultraviolet" +) + +func renderScrolledViewport( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) []string { + allLines := make([]string, 0, len(scrollbackLines)+len(screenLines)) + + for _, line := range scrollbackLines { + allLines = append(allLines, line.Render()) + } + allLines = append(allLines, screenLines...) + + totalLines := len(allLines) + end := totalLines - scrollOffset + if end < 0 { + end = 0 + } + start := end - viewportHeight + if start < 0 { + start = 0 + } + if end > totalLines { + end = totalLines + } + + visible := allLines[start:end] + + for len(visible) < viewportHeight { + visible = append([]string{""}, visible...) + } + + return visible +} + +func renderScrolledOutput( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) string { + lines := renderScrolledViewport(scrollbackLines, screenLines, viewportHeight, scrollOffset) + return strings.Join(lines, "\n") +} diff --git a/internal/tui/pty_scroll_test.go b/internal/tui/pty_scroll_test.go new file mode 100644 index 0000000..e7fe236 --- /dev/null +++ b/internal/tui/pty_scroll_test.go @@ -0,0 +1,73 @@ +package tui + +import ( + "strings" + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +const ( + scrollTestLineWidth = 10 + scrollTestViewportThree = 3 + scrollTestViewportTwo = 2 + scrollTestOffsetOne = 1 + scrollTestOffsetFour = 4 + + scrollTestVisible2 = "visible-2" + scrollTestVis1 = "vis-1" + scrollTestVis2 = "vis-2" + scrollTestExpectFmt = "expected %d lines, got %d" + scrollTestExpectStrFmt = "expected %s, got %q" +) + +func TestRenderScrolledViewport(testing *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(scrollTestLineWidth), + uv.NewLine(scrollTestLineWidth), + } + screenLines := []string{"visible-1", scrollTestVisible2, "visible-3"} + + result := renderScrolledViewport(scrollbackLines, screenLines, scrollTestViewportThree, scrollTestOffsetOne) + + if len(result) != scrollTestViewportThree { + testing.Fatalf(scrollTestExpectFmt, scrollTestViewportThree, len(result)) + } + + if !strings.Contains(result[scrollTestViewportThree-1], scrollTestVisible2) { + testing.Errorf("expected %s at bottom, got %q", scrollTestVisible2, result[scrollTestViewportThree-1]) + } +} + +func TestRenderScrolledViewportAtMaxOffset(testing *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(scrollTestLineWidth), + uv.NewLine(scrollTestLineWidth), + } + screenLines := []string{scrollTestVis1, scrollTestVis2} + + result := renderScrolledViewport(scrollbackLines, screenLines, scrollTestViewportTwo, scrollTestOffsetFour) + + if len(result) != scrollTestViewportTwo { + testing.Fatalf(scrollTestExpectFmt, scrollTestViewportTwo, len(result)) + } +} + +func TestRenderScrolledViewportZeroOffset(testing *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(scrollTestLineWidth), + } + screenLines := []string{scrollTestVis1, scrollTestVis2} + + result := renderScrolledViewport(scrollbackLines, screenLines, scrollTestViewportTwo, 0) + + if len(result) != scrollTestViewportTwo { + testing.Fatalf(scrollTestExpectFmt, scrollTestViewportTwo, len(result)) + } + if result[0] != scrollTestVis1 { + testing.Errorf(scrollTestExpectStrFmt, scrollTestVis1, result[0]) + } + if result[1] != scrollTestVis2 { + testing.Errorf(scrollTestExpectStrFmt, scrollTestVis2, result[1]) + } +} diff --git a/internal/tui/pty_session.go b/internal/tui/pty_session.go index 8303d4e..98fb976 100644 --- a/internal/tui/pty_session.go +++ b/internal/tui/pty_session.go @@ -5,8 +5,10 @@ import ( "io" "os" "os/exec" + "strings" "sync" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/vt" "github.com/creack/pty" ) @@ -16,6 +18,7 @@ const ( defaultTermRows = 24 ptyReadBufSize = 4096 ptyTermEnvVar = "TERM=xterm-256color" + scrollStep = 3 ) type PTYSessionConfig struct { @@ -26,16 +29,17 @@ type PTYSessionConfig struct { } type PTYSession struct { - threadID string - command string - args []string - cmd *exec.Cmd - ptmx *os.File - emulator *vt.SafeEmulator - mu sync.Mutex - running bool - exitCode int - sendMsg func(PTYOutputMsg) + threadID string + command string + args []string + cmd *exec.Cmd + ptmx *os.File + emulator *vt.SafeEmulator + mu sync.Mutex + running bool + exitCode int + scrollOffset int + sendMsg func(PTYOutputMsg) } func NewPTYSession(config PTYSessionConfig) *PTYSession { @@ -81,9 +85,9 @@ func (ps *PTYSession) Start() error { func (ps *PTYSession) readLoop() { buf := make([]byte, ptyReadBufSize) for { - n, err := ps.ptmx.Read(buf) - if n > 0 { - ps.emulator.Write(buf[:n]) + bytesRead, err := ps.ptmx.Read(buf) + if bytesRead > 0 { + ps.emulator.Write(buf[:bytesRead]) ps.sendMsg(PTYOutputMsg{ThreadID: ps.threadID}) } if err != nil { @@ -104,15 +108,9 @@ func (ps *PTYSession) readLoop() { func (ps *PTYSession) responseLoop() { buf := make([]byte, ptyReadBufSize) for { - n, err := ps.emulator.Read(buf) - if n > 0 { - ps.mu.Lock() - ptmx := ps.ptmx - ps.mu.Unlock() - - if ptmx != nil { - ptmx.Write(buf[:n]) - } + bytesRead, err := ps.emulator.Read(buf) + if bytesRead > 0 { + ps.writeResponseToPTY(buf[:bytesRead]) } if err != nil { return @@ -120,6 +118,17 @@ func (ps *PTYSession) responseLoop() { } } +func (ps *PTYSession) writeResponseToPTY(data []byte) { + ps.mu.Lock() + ptmx := ps.ptmx + ps.mu.Unlock() + + if ptmx == nil { + return + } + ptmx.Write(data) +} + func (ps *PTYSession) WriteBytes(data []byte) error { ps.mu.Lock() isRunning := ps.running @@ -154,7 +163,34 @@ func (ps *PTYSession) Resize(width int, height int) { } func (ps *PTYSession) Render() string { - return ps.emulator.Render() + ps.mu.Lock() + offset := ps.scrollOffset + ps.mu.Unlock() + + if offset == 0 { + return ps.emulator.Render() + } + + return ps.renderScrolled(offset) +} + +func (ps *PTYSession) renderScrolled(offset int) string { + scrollback := ps.emulator.Scrollback() + scrollbackLen := scrollback.Len() + scrollbackLines := make([]uv.Line, scrollbackLen) + for index := 0; index < scrollbackLen; index++ { + scrollbackLines[index] = scrollback.Line(index) + } + + screenContent := ps.emulator.Render() + screenLines := strings.Split(screenContent, "\n") + + return renderScrolledOutput( + scrollbackLines, + screenLines, + ps.emulator.Height(), + offset, + ) } func (ps *PTYSession) Stop() { @@ -165,7 +201,8 @@ func (ps *PTYSession) Stop() { ps.ptmx.Close() } - if ps.cmd != nil && ps.cmd.Process != nil { + hasProcess := ps.cmd != nil && ps.cmd.Process != nil + if hasProcess { ps.cmd.Process.Kill() ps.cmd.Wait() } @@ -189,6 +226,48 @@ func (ps *PTYSession) ThreadID() string { return ps.threadID } +func (ps *PTYSession) ScrollUp(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + maxOffset := ps.emulator.ScrollbackLen() + ps.scrollOffset += lines + if ps.scrollOffset > maxOffset { + ps.scrollOffset = maxOffset + } +} + +func (ps *PTYSession) ScrollDown(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset -= lines + if ps.scrollOffset < 0 { + ps.scrollOffset = 0 + } +} + +func (ps *PTYSession) ScrollToBottom() { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset = 0 +} + +func (ps *PTYSession) ScrollOffset() int { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset +} + +func (ps *PTYSession) IsScrolledUp() bool { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset > 0 +} + var _ io.Writer = (*PTYSession)(nil) func (ps *PTYSession) Write(data []byte) (int, error) { diff --git a/internal/tui/pty_session_test.go b/internal/tui/pty_session_test.go index b0f5d49..8a53c89 100644 --- a/internal/tui/pty_session_test.go +++ b/internal/tui/pty_session_test.go @@ -7,16 +7,33 @@ import ( "time" ) -const ptyTestTimeout = 5 * time.Second +const ( + ptyTestTimeout = 5 * time.Second + ptyPollInterval = 10 * time.Millisecond + ptyExitChannelBuffer = 10 + ptyResizeWidth = 120 + ptyResizeHeight = 40 + ptyScrollUpAmount = 5 + ptyScrollDownAmount = 3 + + ptyTestThreadID = "t-1" + testCmdEcho = "echo" + testCmdCat = "cat" + testArgHelloPty = "hello pty" + testArgTest = "test" + testStartFailed = "start failed: %v" +) + +func noopSendMsg(msg PTYOutputMsg) {} -func TestPTYSessionStartAndRender(t *testing.T) { +func TestPTYSessionStartAndRender(testing *testing.T) { var mu sync.Mutex var messages []PTYOutputMsg session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "echo", - Args: []string{"hello pty"}, + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgHelloPty}, SendMsg: func(msg PTYOutputMsg) { mu.Lock() messages = append(messages, msg) @@ -25,50 +42,54 @@ func TestPTYSessionStartAndRender(t *testing.T) { }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } deadline := time.After(ptyTestTimeout) for { mu.Lock() - hasExited := false - for _, msg := range messages { - if msg.Exited { - hasExited = true - break - } - } + snapshot := make([]PTYOutputMsg, len(messages)) + copy(snapshot, messages) mu.Unlock() - if hasExited { + if checkForExit(snapshot) { break } select { case <-deadline: - t.Fatal("timed out waiting for process exit") + testing.Fatal("timed out waiting for process exit") default: - time.Sleep(10 * time.Millisecond) + time.Sleep(ptyPollInterval) } } rendered := session.Render() - if !strings.Contains(rendered, "hello pty") { - t.Errorf("expected 'hello pty' in render output, got %q", rendered) + if !strings.Contains(rendered, testArgHelloPty) { + testing.Errorf("expected 'hello pty' in render output, got %q", rendered) } if session.Running() { - t.Error("expected session to not be running after exit") + testing.Error("expected session to not be running after exit") } } -func TestPTYSessionWriteBytes(t *testing.T) { +func checkForExit(messages []PTYOutputMsg) bool { + for _, msg := range messages { + if msg.Exited { + return true + } + } + return false +} + +func TestPTYSessionWriteBytes(testing *testing.T) { var mu sync.Mutex var gotOutput bool session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "cat", + ThreadID: ptyTestThreadID, + Command: testCmdCat, SendMsg: func(msg PTYOutputMsg) { mu.Lock() if !msg.Exited { @@ -79,13 +100,13 @@ func TestPTYSessionWriteBytes(t *testing.T) { }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } defer session.Stop() err := session.WriteBytes([]byte("test input\n")) if err != nil { - t.Fatalf("write failed: %v", err) + testing.Fatalf("write failed: %v", err) } deadline := time.After(ptyTestTimeout) @@ -100,41 +121,41 @@ func TestPTYSessionWriteBytes(t *testing.T) { select { case <-deadline: - t.Fatal("timed out waiting for cat output") + testing.Fatal("timed out waiting for cat output") default: - time.Sleep(10 * time.Millisecond) + time.Sleep(ptyPollInterval) } } rendered := session.Render() if !strings.Contains(rendered, "test input") { - t.Errorf("expected 'test input' in render output, got %q", rendered) + testing.Errorf("expected 'test input' in render output, got %q", rendered) } } -func TestPTYSessionResize(t *testing.T) { +func TestPTYSessionResize(testing *testing.T) { session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "echo", + ThreadID: ptyTestThreadID, + Command: testCmdEcho, Args: []string{"resize test"}, - SendMsg: func(msg PTYOutputMsg) {}, + SendMsg: noopSendMsg, }) - session.Resize(120, 40) + session.Resize(ptyResizeWidth, ptyResizeHeight) - if session.emulator.Width() != 120 { - t.Errorf("expected width 120, got %d", session.emulator.Width()) + if session.emulator.Width() != ptyResizeWidth { + testing.Errorf("expected width %d, got %d", ptyResizeWidth, session.emulator.Width()) } - if session.emulator.Height() != 40 { - t.Errorf("expected height 40, got %d", session.emulator.Height()) + if session.emulator.Height() != ptyResizeHeight { + testing.Errorf("expected height %d, got %d", ptyResizeHeight, session.emulator.Height()) } } -func TestPTYSessionExitCallback(t *testing.T) { - exitCh := make(chan PTYOutputMsg, 10) +func TestPTYSessionExitCallback(testing *testing.T) { + exitCh := make(chan PTYOutputMsg, ptyExitChannelBuffer) session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", + ThreadID: ptyTestThreadID, Command: "true", SendMsg: func(msg PTYOutputMsg) { exitCh <- msg @@ -142,63 +163,117 @@ func TestPTYSessionExitCallback(t *testing.T) { }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } deadline := time.After(ptyTestTimeout) for { select { case msg := <-exitCh: - if msg.Exited { - if msg.ThreadID != "t-1" { - t.Errorf("expected thread ID t-1, got %s", msg.ThreadID) - } - return + if !msg.Exited { + continue } + if msg.ThreadID != ptyTestThreadID { + testing.Errorf("expected thread ID %s, got %s", ptyTestThreadID, msg.ThreadID) + } + return case <-deadline: - t.Fatal("timed out waiting for exit callback") + testing.Fatal("timed out waiting for exit callback") } } } -func TestPTYSessionWriteAfterStop(t *testing.T) { +func TestPTYSessionWriteAfterStop(testing *testing.T) { session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "cat", - SendMsg: func(msg PTYOutputMsg) {}, + ThreadID: ptyTestThreadID, + Command: testCmdCat, + SendMsg: noopSendMsg, }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } session.Stop() err := session.WriteBytes([]byte("should fail")) if err == nil { - t.Error("expected error writing after stop") + testing.Error("expected error writing after stop") } } -func TestPTYSessionStop(t *testing.T) { +func TestPTYSessionStop(testing *testing.T) { session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", + ThreadID: ptyTestThreadID, Command: "sleep", Args: []string{"60"}, - SendMsg: func(msg PTYOutputMsg) {}, + SendMsg: noopSendMsg, }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } if !session.Running() { - t.Error("expected session to be running") + testing.Error("expected session to be running") } session.Stop() if session.Running() { - t.Error("expected session to be stopped") + testing.Error("expected session to be stopped") + } +} + +func TestPTYSessionScrollOffset(testing *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgTest}, + SendMsg: noopSendMsg, + }) + + if session.ScrollOffset() != 0 { + testing.Errorf("expected initial offset 0, got %d", session.ScrollOffset()) + } + + if session.IsScrolledUp() { + testing.Error("expected not scrolled up initially") + } +} + +func TestPTYSessionScrollUpDown(testing *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgTest}, + SendMsg: noopSendMsg, + }) + + session.ScrollUp(ptyScrollUpAmount) + if session.ScrollOffset() != 0 { + testing.Errorf("expected offset 0 with no scrollback, got %d", session.ScrollOffset()) + } + + session.ScrollDown(ptyScrollDownAmount) + if session.ScrollOffset() != 0 { + testing.Errorf("expected offset 0 after scroll down, got %d", session.ScrollOffset()) + } +} + +func TestPTYSessionScrollToBottom(testing *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgTest}, + SendMsg: noopSendMsg, + }) + + session.ScrollToBottom() + if session.ScrollOffset() != 0 { + testing.Errorf("expected offset 0 after scroll to bottom, got %d", session.ScrollOffset()) + } + if session.IsScrolledUp() { + testing.Error("expected not scrolled up after scroll to bottom") } }