From 2496766f589a23c66e8d89138911594cd4ccc959 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sun, 22 Mar 2026 00:07:42 +0200 Subject: [PATCH 1/7] feat(ui): sidebar resize, preview cursor, and housekeeping - Add [ / ] keybindings to resize sidebar width - Add cursor position tracking in preview pane - Add sidebar_pct config option - Expand .gitignore with standard Go patterns - Use install command in Makefile --- .gitignore | 33 ++++++++ CLAUDE.md | 4 +- Makefile | 2 +- internal/config/config.go | 48 +++++++++++ internal/config/config_test.go | 145 ++++++++++++++++++++++++++++++++- internal/ui/preview.go | 55 ++++++++++++- internal/ui/preview_test.go | 135 ++++++++++++++++++++++++++++++ internal/ui/resize_test.go | 82 +++++++++++++++++++ 8 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 internal/ui/preview_test.go create mode 100644 internal/ui/resize_test.go diff --git a/.gitignore b/.gitignore index 4a57cc60..911a68b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,36 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories +vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# MacOS +.DS_Store + +# build artifacts build/ /fleet *.log diff --git a/CLAUDE.md b/CLAUDE.md index 8aa6b24a..9dc77cc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Status: Running, Waiting, Finished, Idle, Error, Starting - Status icons: ● (running/finished), ◐ (waiting), ○ (idle/starting), ✕ (error) - Agent glyph: each session row shows a dim, monochrome per-agent sigil between the status dot and the title — `✻` Claude, `◇` Codex (`agentGlyph` + `AgentGlyphStyle` in `sidebar.go`/`styles.go`); both are width-1 glyphs from well-covered Unicode blocks (Dingbats / Geometric Shapes — same block as the status dots) so they stay aligned in base mono fonts; identity is carried by shape so the status dot keeps sole ownership of color; empty/legacy `Agent` falls back to Claude -- Keybindings: j/k nav, Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch), F fork to worktree (Claude-only), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin), u undo delete (5s window), r restart, R rename, e editor, p open PR in browser, Y quick approve (waiting sessions), / filter, Ctrl+K command palette, S settings, ! bug report/diagnostics, ? help, q quit +- Keybindings: j/k nav, [ / ] resize sidebar, Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch), F fork to worktree (Claude-only), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin), u undo delete (5s window), r restart, R rename, e editor, p open PR in browser, Y quick approve (waiting sessions), / filter, Ctrl+K command palette, S settings, ! bug report/diagnostics, ? help, q quit - Session hotkeys (RTS-style): `Alt+0-9` (or `=` then digit) binds the selected session to a slot; re-pressing `Alt+` on a session already in slot N unbinds; `==` then digit clears any slot; plain `0-9` jumps to the bound session (double-tap within 400ms also attaches); `[N]` badge in sidebar marks bound sessions; bindings persist in SQLite `slot_bindings` table (FK cascade on session delete) - Command palette (Ctrl+K): renders as an overlay over the sidebar/preview (not a full-screen takeover); fuzzy-searches commands plus every repo/worktree currently in the sidebar (name, branch, full path all matched); picking a repo/worktree jumps the sidebar cursor to that header (auto-expand if collapsed); palette-only commands include "Reload All Sessions" (restarts all dead/error sessions). For a native Cmd+K feel on macOS, map Cmd+K → Ctrl+K in your terminal prefs (iTerm2: Profiles → Keys → Key Mappings → +; Ghostty: `keybind = cmd+k=text:\x0b`). - Undo delete: `u` key restores last deleted session within 5s window (stacked — multiple deletes each undoable). Tmux kept alive during window for full restore. @@ -103,7 +103,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Hook handler: `fleet hook-handler` (invoked by Claude Code hooks, reads FLEET_INSTANCE_ID env) - Hooks auto-installed into `~/.claude/settings.json` on TUI launch - Debug log: `~/.config/fleet/debug.log` (slog, init in TUI and hook-handler) -- Config file: `~/.config/fleet/config.json` (tick_interval_sec, default_project_path, editor, theme, auto_name_sessions, copy_claude_settings) +- Config file: `~/.config/fleet/config.json` (tick_interval_sec, default_project_path, editor, theme, auto_name_sessions, copy_claude_settings, sidebar_pct) - Workspace: built-in git worktree support (zero config), per-repo `.fleet.json` (or legacy `.bc.json`) overrides with custom shell commands - Workspace creation is non-blocking: dialog closes immediately, phantom "Creating..." entry with spinner appears in sidebar, user can keep navigating - Worktree creation copies `.claude/settings.local.json` from source repo (configurable via `copy_claude_settings`, default true) diff --git a/Makefile b/Makefile index 2b646ca5..32427ff8 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ vet: go vet ./... install: build - cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/ + install -m 755 $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY) setup: pre-commit install diff --git a/internal/config/config.go b/internal/config/config.go index 81650450..1aa9cb05 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { StatusIndicator string `json:"status_indicator,omitempty"` // "icon" (default) or "bar" Telemetry *bool `json:"telemetry,omitempty"` DefaultAgent string `json:"default_agent,omitempty"` // "claude" or "codex" + SidebarPct *int `json:"sidebar_pct,omitempty"` // Sidebar display toggles. All default to true (on) via the *bool nil // pattern, so an unconfigured fleet renders the full vocabulary. Each is @@ -232,6 +233,53 @@ func (c *Config) IsTelemetryEnabled() bool { return *c.Telemetry } +// GetSidebarPct returns the sidebar width percentage, clamped to [20, 60], default 35. +func (c *Config) GetSidebarPct() int { + if c.SidebarPct == nil { + return 35 + } + v := *c.SidebarPct + if v < 20 { + return 20 + } + if v > 60 { + return 60 + } + return v +} + +// SetSidebarPct sets the sidebar width percentage, clamping to [20, 60]. +func (c *Config) SetSidebarPct(pct int) { + if pct < 20 { + pct = 20 + } + if pct > 60 { + pct = 60 + } + c.SidebarPct = &pct +} + +// StepSidebarPct adjusts the sidebar percentage by ~2.5 in the given direction +// (dir > 0 = grow, dir < 0 = shrink). Since the stored value is an integer, steps +// alternate between 3 and 2 to produce an effective 2.5% increment on the grid: +// 20, 23, 25, 28, 30, 33, 35, 38, 40, 43, 45, 48, 50, 53, 55, 58, 60. +func (c *Config) StepSidebarPct(dir int) { + cur := c.GetSidebarPct() + if dir > 0 { + if cur%5 == 0 { + c.SetSidebarPct(cur + 3) + } else { + c.SetSidebarPct(cur + 2) + } + } else { + if cur%5 == 0 { + c.SetSidebarPct(cur - 2) + } else { + c.SetSidebarPct(cur - 3) + } + } +} + // GetEditor returns the configured editor, falling back to $EDITOR then "code". func (c *Config) GetEditor() string { if c.Editor != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 24787eda..a7328160 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -166,6 +166,144 @@ func TestIsTelemetryEnabled(t *testing.T) { }) } +func TestGetSidebarPct(t *testing.T) { + t.Run("nil defaults to 35", func(t *testing.T) { + cfg := &Config{} + if got := cfg.GetSidebarPct(); got != 35 { + t.Errorf("GetSidebarPct() = %d, want 35", got) + } + }) + + t.Run("value within range", func(t *testing.T) { + v := 40 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 40 { + t.Errorf("GetSidebarPct() = %d, want 40", got) + } + }) + + t.Run("min boundary", func(t *testing.T) { + v := 20 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 20 { + t.Errorf("GetSidebarPct() = %d, want 20", got) + } + }) + + t.Run("max boundary", func(t *testing.T) { + v := 60 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 60 { + t.Errorf("GetSidebarPct() = %d, want 60", got) + } + }) + + t.Run("below min clamps to 20", func(t *testing.T) { + v := 10 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 20 { + t.Errorf("GetSidebarPct() = %d, want 20", got) + } + }) + + t.Run("above max clamps to 60", func(t *testing.T) { + v := 80 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 60 { + t.Errorf("GetSidebarPct() = %d, want 60", got) + } + }) + + t.Run("zero clamps to 20", func(t *testing.T) { + v := 0 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 20 { + t.Errorf("GetSidebarPct() = %d, want 20", got) + } + }) + + t.Run("negative clamps to 20", func(t *testing.T) { + v := -5 + cfg := &Config{SidebarPct: &v} + if got := cfg.GetSidebarPct(); got != 20 { + t.Errorf("GetSidebarPct() = %d, want 20", got) + } + }) +} + +func TestSetSidebarPct(t *testing.T) { + t.Run("normal value", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(40) + if cfg.SidebarPct == nil || *cfg.SidebarPct != 40 { + t.Errorf("expected 40, got %v", cfg.SidebarPct) + } + }) + + t.Run("below min clamps", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(10) + if cfg.SidebarPct == nil || *cfg.SidebarPct != 20 { + t.Errorf("expected 20, got %v", cfg.SidebarPct) + } + }) + + t.Run("above max clamps", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(75) + if cfg.SidebarPct == nil || *cfg.SidebarPct != 60 { + t.Errorf("expected 60, got %v", cfg.SidebarPct) + } + }) +} + +func TestStepSidebarPct(t *testing.T) { + t.Run("full upward sequence from 20", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(20) + // Expected grid: 20, 23, 25, 28, 30, 33, 35, 38, 40, 43, 45, 48, 50, 53, 55, 58, 60 + expected := []int{23, 25, 28, 30, 33, 35, 38, 40, 43, 45, 48, 50, 53, 55, 58, 60} + for i, want := range expected { + cfg.StepSidebarPct(1) + got := cfg.GetSidebarPct() + if got != want { + t.Errorf("step %d: got %d, want %d", i+1, got, want) + } + } + }) + + t.Run("full downward sequence from 60", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(60) + expected := []int{58, 55, 53, 50, 48, 45, 43, 40, 38, 35, 33, 30, 28, 25, 23, 20} + for i, want := range expected { + cfg.StepSidebarPct(-1) + got := cfg.GetSidebarPct() + if got != want { + t.Errorf("step %d: got %d, want %d", i+1, got, want) + } + } + }) + + t.Run("clamps at min", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(20) + cfg.StepSidebarPct(-1) + if got := cfg.GetSidebarPct(); got != 20 { + t.Errorf("got %d, want 20 (clamped at min)", got) + } + }) + + t.Run("clamps at max", func(t *testing.T) { + cfg := &Config{} + cfg.SetSidebarPct(60) + cfg.StepSidebarPct(1) + if got := cfg.GetSidebarPct(); got != 60 { + t.Errorf("got %d, want 60 (clamped at max)", got) + } + }) +} + func TestGetEnterMode(t *testing.T) { tests := []struct { name string @@ -192,6 +330,7 @@ func TestConfigJSONRoundTrip(t *testing.T) { autoName := true autoUpdate := false copySettings := true + sidebarPct := 45 original := &Config{ TickIntervalSec: 5, DefaultProjectPath: "/home/user/projects", @@ -201,6 +340,7 @@ func TestConfigJSONRoundTrip(t *testing.T) { AutoUpdate: &autoUpdate, CopyClaudeSettings: ©Settings, EnterMode: "split", + SidebarPct: &sidebarPct, } data, err := json.Marshal(original) @@ -237,6 +377,9 @@ func TestConfigJSONRoundTrip(t *testing.T) { if loaded.EnterMode != original.EnterMode { t.Errorf("EnterMode: got %q, want %q", loaded.EnterMode, original.EnterMode) } + if loaded.SidebarPct == nil || *loaded.SidebarPct != *original.SidebarPct { + t.Errorf("SidebarPct mismatch") + } } func TestConfigUnmarshalPartialJSON(t *testing.T) { @@ -287,7 +430,7 @@ func TestConfigOmitEmptyFields(t *testing.T) { } // With omitempty, zero-value fields should not be present. - for _, key := range []string{"editor", "theme", "default_project_path", "enter_mode"} { + for _, key := range []string{"editor", "theme", "default_project_path", "enter_mode", "sidebar_pct"} { if _, ok := raw[key]; ok { t.Errorf("expected %q to be omitted for zero value", key) } diff --git a/internal/ui/preview.go b/internal/ui/preview.go index 6418e016..7ec7785e 100644 --- a/internal/ui/preview.go +++ b/internal/ui/preview.go @@ -6,9 +6,11 @@ import ( "strings" "time" + "github.com/brizzai/fleet/internal/debuglog" "github.com/brizzai/fleet/internal/git" "github.com/brizzai/fleet/internal/github" "github.com/brizzai/fleet/internal/session" + "github.com/brizzai/fleet/internal/tmux" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) @@ -18,7 +20,9 @@ import ( // (name, status, PR, path, last-used) now rides the panel border via // BuildPreviewTitle / BuildPreviewFooter, freeing 5+ rows of vertical space // previously eaten by a metadata header block. -func RenderPreview(s *session.Session, content string, repoInfo *git.RepoInfo, width, height int, focused bool) string { +// +// cursor, when non-nil, renders a block cursor at the tmux pane's cursor position. +func RenderPreview(s *session.Session, content string, repoInfo *git.RepoInfo, width, height int, focused bool, cursor *tmux.CursorPosition) string { if s == nil { return DimStyle.Render(" No session selected") } @@ -64,16 +68,42 @@ func RenderPreview(s *session.Session, content string, repoInfo *git.RepoInfo, w if start < 0 { start = 0 } + + if cursor != nil { + debuglog.Logger.Debug("cursor overlay", + "cursor_x", cursor.X, "cursor_y", cursor.Y, + "total_lines", len(lines), "start", start, + "content_height", contentHeight, "width", width, + "in_range", cursor.Y >= start && cursor.Y < len(lines), + "x_in_bounds", cursor.X < width-2) + } + + cursorRendered := false for i := start; i < len(lines); i++ { line := lines[i] if ansi.StringWidth(line) > width-2 { line = ansi.Truncate(line, width-2, "") } + // Overlay cursor on the matching line. + // Skip if cursor column is past the truncated visible width. + if cursor != nil && i == cursor.Y && cursor.X < width-2 { + line = overlayCursor(line, cursor.X) + cursorRendered = true + } + // Reset ANSI at end of each line to prevent background color bleed. b.WriteString(" " + line + "\x1b[0m") if i < len(lines)-1 { b.WriteString("\n") } } + + if cursor != nil && !cursorRendered { + debuglog.Logger.Warn("cursor NOT rendered", + "cursor_x", cursor.X, "cursor_y", cursor.Y, + "total_lines", len(lines), "start", start, + "content_height", contentHeight) + } + return b.String() } @@ -253,6 +283,29 @@ func relativeTime(t time.Time) string { } } +// cursorOn and cursorOff are ANSI sequences for a visible block cursor. +// Uses reverse video + bright white background for maximum visibility. +const ( + cursorOn = "\x1b[7m" // SGR reverse video + cursorOff = "\x1b[27m" // SGR reverse video off +) + +// overlayCursor injects a reverse-video block cursor at column col in an ANSI-encoded line. +func overlayCursor(line string, col int) string { + lineWidth := ansi.StringWidth(line) + if col >= lineWidth { + // Cursor is past end of visible text — pad to cursor position, then show block. + pad := col - lineWidth + return line + strings.Repeat(" ", pad) + cursorOn + " " + cursorOff + } + // Split line at cursor column using ANSI-aware cut. + before := ansi.Truncate(line, col, "") + // Extract the single character at cursor position. + charAtCursor := ansi.Cut(line, col, col+1) + after := ansi.TruncateLeft(line, col+1, "") + return before + cursorOn + charAtCursor + cursorOff + after +} + // stripOSC8 removes OSC-8 hyperlink sequences while preserving the visible link text. // OSC-8 format: ESC]8;params;uri ST ... visible text ... ESC]8;;ST // where ST is BEL (\x07) or ESC\ (\x1b\x5c). diff --git a/internal/ui/preview_test.go b/internal/ui/preview_test.go new file mode 100644 index 00000000..fa5792a1 --- /dev/null +++ b/internal/ui/preview_test.go @@ -0,0 +1,135 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/brizzai/fleet/internal/session" + "github.com/brizzai/fleet/internal/tmux" + "github.com/charmbracelet/x/ansi" +) + +func TestOverlayCursor_PlainText(t *testing.T) { + tests := []struct { + name string + line string + col int + wantSub string // substring that should appear (the reverse-video block) + }{ + { + name: "cursor at start", + line: "hello", + col: 0, + wantSub: "\x1b[7mh\x1b[27m", + }, + { + name: "cursor in middle", + line: "hello", + col: 2, + wantSub: "\x1b[7ml\x1b[27m", + }, + { + name: "cursor at last char", + line: "hello", + col: 4, + wantSub: "\x1b[7mo\x1b[27m", + }, + { + name: "cursor past end appends space", + line: "hello", + col: 5, + wantSub: "\x1b[7m \x1b[27m", + }, + { + name: "cursor far past end appends space", + line: "hi", + col: 10, + wantSub: "\x1b[7m \x1b[27m", + }, + { + name: "empty line cursor at 0", + line: "", + col: 0, + wantSub: "\x1b[7m \x1b[27m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := overlayCursor(tt.line, tt.col) + if !strings.Contains(got, tt.wantSub) { + t.Errorf("overlayCursor(%q, %d) = %q, want substring %q", tt.line, tt.col, got, tt.wantSub) + } + // Visible width: cursor at col adds padding + 1 block char when past end. + gotWidth := ansi.StringWidth(got) + origWidth := ansi.StringWidth(tt.line) + if tt.col >= origWidth { + // Width = col + 1 (padding to cursor position + cursor block). + wantWidth := tt.col + 1 + if gotWidth != wantWidth { + t.Errorf("width: got %d, want %d (col %d + 1 for cursor)", gotWidth, wantWidth, tt.col) + } + } else { + if gotWidth != origWidth { + t.Errorf("width: got %d, want %d (same as original)", gotWidth, origWidth) + } + } + }) + } +} + +func TestOverlayCursor_WithANSI(t *testing.T) { + // Line with ANSI color codes: "\x1b[32mhello\x1b[0m" (green "hello") + line := "\x1b[32mhello\x1b[0m" + + got := overlayCursor(line, 2) + // Should contain reverse video marker. + if !strings.Contains(got, "\x1b[7m") { + t.Errorf("expected reverse video in output: %q", got) + } + // Visible width should be preserved (5 chars). + if w := ansi.StringWidth(got); w != 5 { + t.Errorf("width: got %d, want 5", w) + } +} + +func TestOverlayCursor_PreservesContent(t *testing.T) { + line := "abcdef" + got := overlayCursor(line, 3) + // Strip ANSI to get plain text — should still be "abcdef". + plain := ansi.Strip(got) + if plain != "abcdef" { + t.Errorf("plain text should be preserved: got %q, want %q", plain, "abcdef") + } +} + +func TestRenderPreview_CursorSkippedWhenOffScreen(t *testing.T) { + // Simulate a line that would be truncated to width-2 columns. + // Cursor at column 50 should be skipped when preview width is 40 (visible=38). + longLine := strings.Repeat("x", 60) + cursor := &tmux.CursorPosition{X: 50, Y: 0} + + // Build a minimal session for RenderPreview. + s := &session.Session{Title: "test", ProjectPath: "/tmp"} + s.SetStatus(session.StatusRunning) + + result := RenderPreview(s, longLine, nil, 40, 10, true, cursor) + // The cursor should NOT appear since cursor.X (50) >= width-2 (38). + if strings.Contains(result, "\x1b[7m") { + t.Error("cursor overlay should not render when cursor column is past visible width") + } +} + +func TestRenderPreview_CursorRenderedWhenOnScreen(t *testing.T) { + line := "hello world" + cursor := &tmux.CursorPosition{X: 3, Y: 0} + + s := &session.Session{Title: "test", ProjectPath: "/tmp"} + s.SetStatus(session.StatusRunning) + + result := RenderPreview(s, line, nil, 80, 10, true, cursor) + // The reverse-video cursor should be present. + if !strings.Contains(result, "\x1b[7m") { + t.Error("cursor overlay should render when cursor column is within visible width") + } +} diff --git a/internal/ui/resize_test.go b/internal/ui/resize_test.go new file mode 100644 index 00000000..8dc6c327 --- /dev/null +++ b/internal/ui/resize_test.go @@ -0,0 +1,82 @@ +package ui + +import ( + "testing" + + "github.com/brizzai/fleet/internal/config" +) + +func TestSidebarWidth(t *testing.T) { + tests := []struct { + name string + termWidth int + sidebarPct *int + wantWidth int + }{ + { + name: "default 35% at 120 cols", + termWidth: 120, + wantWidth: 42, // 120 * 35 / 100 + }, + { + name: "default 35% at 100 cols", + termWidth: 100, + wantWidth: 35, + }, + { + name: "40% at 120 cols", + termWidth: 120, + sidebarPct: intP(40), + wantWidth: 48, + }, + { + name: "60% at 120 cols", + termWidth: 120, + sidebarPct: intP(60), + wantWidth: 72, + }, + { + name: "20% at 120 cols", + termWidth: 120, + sidebarPct: intP(20), + wantWidth: 24, + }, + { + name: "min sidebar 20 enforced", + termWidth: 80, + sidebarPct: intP(20), + wantWidth: 20, // 80 * 20 / 100 = 16, clamped to 20 + }, + { + name: "preview min 30 enforced", + termWidth: 60, + sidebarPct: intP(60), + wantWidth: 27, // 60 - 33 = 27 (preview gets 30) + }, + { + name: "very narrow terminal prefers sidebar min", + termWidth: 53, + sidebarPct: intP(60), + wantWidth: 20, // 53 - 33 = 20 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + if tt.sidebarPct != nil { + cfg.SetSidebarPct(*tt.sidebarPct) + } + h := &Home{ + width: tt.termWidth, + cfg: cfg, + } + got := h.sidebarWidth() + if got != tt.wantWidth { + t.Errorf("sidebarWidth() = %d, want %d", got, tt.wantWidth) + } + }) + } +} + +func intP(v int) *int { return &v } From 4ea66b3afef674c5ad6647d65ebc5dc1c438d134 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sun, 22 Mar 2026 00:08:07 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20shell=20sessions=20=E2=80=94=20open?= =?UTF-8?q?=20terminal=20in=20session=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press `x` to spawn a plain shell session in the selected session's working directory. The session is tracked in the sidebar with real-time status indicators: - Green dot: command actively running (via tmux pane_current_command) - Dim circle: idle at shell prompt - Red X: last command had non-zero exit code (via precmd hook) Shell sessions are first-class: attach/detach with Enter/Ctrl+Q, restart with r, delete with d, persisted across app restarts. Resolves #28 --- docs/features/shell-sessions.md | 66 ++++++++++++ internal/analytics/events.go | 1 + internal/session/session.go | 115 +++++++++++++++++++-- internal/session/storage.go | 20 +++- internal/tmux/tmux.go | 60 +++++++++++ internal/ui/app.go | 174 ++++++++++++++++++++++++++------ internal/ui/keybindings.go | 2 + 7 files changed, 394 insertions(+), 44 deletions(-) create mode 100644 docs/features/shell-sessions.md diff --git a/docs/features/shell-sessions.md b/docs/features/shell-sessions.md new file mode 100644 index 00000000..cd58b695 --- /dev/null +++ b/docs/features/shell-sessions.md @@ -0,0 +1,66 @@ +# Shell Sessions + +Open a plain terminal in any session's working directory without leaving brizz-code. + +## Usage + +1. Select a session in the sidebar +2. Press **`x`** to open a shell in that session's `ProjectPath` +3. Use the terminal normally (full shell with colors, aliases, environment) +4. Press **Ctrl+Q** to detach back to the TUI + +The shell session appears in the sidebar like any other session, titled `shell: `. + +## Lifecycle + +Shell sessions are first-class sessions: + +| Action | Key | Behavior | +|--------|-----|----------| +| Open | `x` | Spawns shell in selected session's directory | +| Attach | `Enter` | Re-attach to an existing shell session | +| Restart | `r` | Kill and recreate the shell session | +| Delete | `d` | Kill the tmux session and remove from sidebar | +| Detach | `Ctrl+Q` | Return to TUI, shell stays alive | + +Sessions are persisted in SQLite and survive app restarts. + +## Status Indicators + +Shell sessions show real-time status in the sidebar: + +| Icon | Status | Meaning | +|------|--------|---------| +| `●` (green) | Running | A foreground command is actively executing | +| `○` (dim) | Idle | At shell prompt, last command succeeded | +| `✕` (red) | Error | At shell prompt, last command had non-zero exit code | + +### How detection works + +**Running vs idle** is detected by comparing tmux's `pane_current_command` against the user's `$SHELL`. When the foreground process differs from the shell (e.g., `make` running in a `zsh` session), the session shows as running. Polled every 500ms via the preview tick. + +**Exit code tracking** uses a `precmd` (zsh) / `PROMPT_COMMAND` (bash) hook injected when the shell session starts. After each command completes, the hook writes `{"exit_code": N}` to `~/.config/brizz-code/hooks/_exit.json`. The status poller reads this file to detect non-zero exit codes. + +## Implementation + +### Key files + +| File | Role | +|------|------| +| `internal/ui/app.go` | `x` key handler, `shellStatusDoneMsg`, preview tick polling | +| `internal/session/session.go` | `Command` field, `IsShellSession()`, `updateShellStatus()` | +| `internal/session/storage.go` | `command` column in SQLite | +| `internal/tmux/tmux.go` | `PaneCurrentCommand()`, `SetupShellExitHook()` | +| `internal/ui/keybindings.go` | `x` keybinding entry | + +### Session model + +The `Session` struct has a `Command` field. When non-empty, the session is a shell session: + +- `Start()` opens the user's shell without sending a command (vs Claude sessions which send `claude`) +- `Restart()` and `RespawnClaude()` respect the `Command` field +- `UpdateStatus()` delegates to `updateShellStatus()` for shell sessions + +### Status polling + +Shell sessions bypass the hook-based status detection used by Claude sessions. Instead, `updateShellSessionStatuses()` runs every 500ms (piggybacked on the preview tick) and checks `PaneCurrentCommand()` for each live shell session. diff --git a/internal/analytics/events.go b/internal/analytics/events.go index c8a49235..408fc923 100644 --- a/internal/analytics/events.go +++ b/internal/analytics/events.go @@ -27,6 +27,7 @@ const ( EventPROpened = "pr_opened" EventUndoUsed = "undo_used" EventForkSession = "fork_session" + EventCommandRun = "command_run" // Workspaces & repos. EventWorkspaceCreated = "workspace_created" diff --git a/internal/session/session.go b/internal/session/session.go index 7893b8ec..3200efd0 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3,6 +3,7 @@ package session import ( "crypto/rand" "crypto/sha256" + "encoding/json" "fmt" "log/slog" "os" @@ -57,6 +58,8 @@ type Session struct { TitleGenerated bool PromptCount int ForkFromID string // Transient: if set, start with --resume --fork-session (cleared after start) + Command string // If set, run this shell command instead of Claude. + ShellExitCode int // Last exit code from shell session (0 = success). hookStatus string hookUpdatedAt time.Time @@ -96,6 +99,11 @@ func NewSession(title, projectPath string) *Session { } } +// IsShellSession returns true if this session runs a shell command instead of an agent. +func (s *Session) IsShellSession() bool { + return s.Command != "" +} + // buildAgentCmd returns the launch command for this session's agent, with // optional resume/fork details. ClaudeSessionID stores the agent's own // conversation id (Claude or Codex) captured from hooks. @@ -124,14 +132,20 @@ func (s *Session) sessionEnv() []string { } } -// Start launches the Claude Code session in tmux. +// Start launches the session in tmux (Claude or shell command). func (s *Session) Start() error { - debuglog.Logger.Info("session start", "id", s.ID, "title", s.Title, "path", s.ProjectPath) + debuglog.Logger.Info("session start", "id", s.ID, "title", s.Title, "path", s.ProjectPath, "command", s.Command) s.mu.Lock() s.Status = StatusStarting s.mu.Unlock() - cmd := s.buildAgentCmd() + var cmd string + if s.IsShellSession() { + // Shell session: start with user's shell, no command sent. + cmd = "" + } else { + cmd = s.buildAgentCmd() + } if err := s.tmuxSession.Start(cmd, s.sessionEnv()...); err != nil { s.mu.Lock() s.Status = StatusError @@ -140,6 +154,10 @@ func (s *Session) Start() error { return err } + if s.IsShellSession() { + s.tmuxSession.SetupShellExitHook(s.ID) + } + s.mu.Lock() s.Status = s.initialRunStatus() s.ForkFromID = "" // Clear after first start so restarts use session's own ClaudeSessionID. @@ -314,7 +332,12 @@ func (s *Session) Restart() error { s.deathRecorded = false s.mu.Unlock() - cmd := s.buildAgentCmd() + var cmd string + if s.IsShellSession() { + cmd = "" + } else { + cmd = s.buildAgentCmd() + } if err := newTmux.Start(cmd, s.sessionEnv()...); err != nil { s.mu.Lock() s.Status = StatusError @@ -323,6 +346,10 @@ func (s *Session) Restart() error { return err } + if s.IsShellSession() { + s.tmuxSession.SetupShellExitHook(s.ID) + } + s.mu.Lock() s.Status = s.initialRunStatus() s.mu.Unlock() @@ -330,17 +357,22 @@ func (s *Session) Restart() error { return nil } -// RespawnClaude restarts the claude process in an existing tmux session. +// RespawnClaude restarts the process in an existing tmux session. func (s *Session) RespawnClaude() error { resuming := s.ClaudeSessionID != "" - debuglog.Logger.Info("session respawn", "id", s.ID, "title", s.Title, "resuming", resuming) + debuglog.Logger.Info("session respawn", "id", s.ID, "title", s.Title, "resuming", resuming, "command", s.Command) s.clearHookState() s.mu.Lock() s.Status = StatusStarting s.deathRecorded = false s.mu.Unlock() - cmd := s.buildAgentCmd() + var cmd string + if s.IsShellSession() { + cmd = "" + } else { + cmd = s.buildAgentCmd() + } if err := s.tmuxSession.RespawnPane(cmd, s.sessionEnv()...); err != nil { s.mu.Lock() s.Status = StatusError @@ -352,6 +384,9 @@ func (s *Session) RespawnClaude() error { // Reset to a baseline status bar; the UI worker will re-apply with // active fleet theme + live state on the next tick. s.tmuxSession.ApplyStatusBar(tmux.StatusBarOpts{}) + if s.IsShellSession() { + s.tmuxSession.SetupShellExitHook(s.ID) + } s.mu.Lock() s.Status = s.initialRunStatus() @@ -398,6 +433,12 @@ func (s *Session) UpdateStatus() { return } + // Shell sessions: detect status from tmux pane_current_command + exit code file. + if s.IsShellSession() { + s.updateShellStatus(oldStatus, log) + return + } + // Hook fast path: hooks are authoritative as long as the session is alive. // No time-based expiry — IsAlive/IsPaneDead above handle stale scenarios. s.mu.RLock() @@ -847,6 +888,64 @@ func (s *Session) updateStatusFromPane(oldStatus Status, log *slog.Logger) { } } +// updateShellStatus detects status for shell sessions using tmux pane_current_command +// and reads exit codes from the hook file written by the precmd/PROMPT_COMMAND hook. +func (s *Session) updateShellStatus(oldStatus Status, log *slog.Logger) { + currentCmd := s.tmuxSession.PaneCurrentCommand() + log.Debug("shell status check", "pane_current_command", currentCmd) + + // Compare against the session's actual shell, not a hardcoded list. + // e.g., if $SHELL is /bin/zsh, only "zsh" means idle — "bash" means running. + userShell := strings.TrimPrefix(filepath.Base(os.Getenv("SHELL")), "-") + if userShell == "" { + userShell = "zsh" // fallback + } + cmd := strings.TrimPrefix(filepath.Base(currentCmd), "-") + isShellPrompt := cmd == userShell || currentCmd == "" + + // Read exit code from hook file if available. + exitFile := shellExitFilePath(s.ID) + if data, err := os.ReadFile(exitFile); err == nil { + var result struct { + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(data, &result); err == nil { + s.mu.Lock() + s.ShellExitCode = result.ExitCode + s.mu.Unlock() + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + if isShellPrompt { + // At shell prompt — command finished (or no command run yet). + if s.ShellExitCode != 0 { + s.Status = StatusError + } else { + s.Status = StatusIdle + } + } else { + // A command is actively running. + s.Status = StatusRunning + s.ShellExitCode = 0 // Reset while running. + } + + if s.Status != oldStatus { + log.Info("shell status changed", "old", oldStatus, "new", s.Status, "command", currentCmd, "exitCode", s.ShellExitCode) + } +} + +// shellExitFilePath returns the path to the shell exit code file for a session. +func shellExitFilePath(sessionID string) string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), ".config", "brizz-code", "hooks", sessionID+"_exit.json") + } + return filepath.Join(home, ".config", "brizz-code", "hooks", sessionID+"_exit.json") +} + // ToRow converts to a storage row. func (s *Session) ToRow() *SessionRow { s.mu.RLock() @@ -867,6 +966,7 @@ func (s *Session) ToRow() *SessionRow { FirstPrompt: s.FirstPrompt, TitleGenerated: s.TitleGenerated, PromptCount: s.PromptCount, + Command: s.Command, } } @@ -942,6 +1042,7 @@ func FromRow(row *SessionRow) *Session { FirstPrompt: row.FirstPrompt, TitleGenerated: row.TitleGenerated, PromptCount: row.PromptCount, + Command: row.Command, tmuxSession: ts, } } diff --git a/internal/session/storage.go b/internal/session/storage.go index dab51b13..5ef1f25a 100644 --- a/internal/session/storage.go +++ b/internal/session/storage.go @@ -51,6 +51,7 @@ type SessionRow struct { FirstPrompt string TitleGenerated bool PromptCount int + Command string } // DefaultDBPath returns the default database path. @@ -224,6 +225,15 @@ func (s *StateDB) migrate() error { return err } + // Add command column for shell sessions. + if !s.hasColumn("sessions", "command") { + _, err = s.db.Exec(`ALTER TABLE sessions ADD COLUMN command TEXT NOT NULL DEFAULT ''`) + if err != nil { + debuglog.Logger.Error("migration failed: add command column", "error", err) + return err + } + } + return nil } @@ -252,14 +262,14 @@ func (s *StateDB) hasColumn(table, column string) bool { // SaveSession inserts or replaces a session row. func (s *StateDB) SaveSession(row *SessionRow) error { _, err := s.db.Exec(` - INSERT OR REPLACE INTO sessions (id, title, project_path, agent, status, tmux_session, created_at, last_accessed, acknowledged, claude_session_id, workspace_name, manually_renamed, first_prompt, title_generated, prompt_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO sessions (id, title, project_path, agent, status, tmux_session, created_at, last_accessed, acknowledged, claude_session_id, workspace_name, manually_renamed, first_prompt, title_generated, prompt_count, command) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, row.ID, row.Title, row.ProjectPath, string(agent.Parse(row.Agent)), row.Status, row.TmuxSession, row.CreatedAt.Unix(), row.LastAccessed.Unix(), boolToInt(row.Acknowledged), row.ClaudeSessionID, row.WorkspaceName, boolToInt(row.ManuallyRenamed), row.FirstPrompt, boolToInt(row.TitleGenerated), - row.PromptCount, + row.PromptCount, row.Command, ) if err != nil { debuglog.Logger.Error("failed to save session", "id", row.ID, "error", err) @@ -270,7 +280,7 @@ func (s *StateDB) SaveSession(row *SessionRow) error { // LoadSessions returns all sessions ordered by creation time. func (s *StateDB) LoadSessions() ([]*SessionRow, error) { rows, err := s.db.Query(` - SELECT id, title, project_path, agent, status, tmux_session, created_at, last_accessed, acknowledged, claude_session_id, workspace_name, manually_renamed, first_prompt, title_generated, prompt_count + SELECT id, title, project_path, agent, status, tmux_session, created_at, last_accessed, acknowledged, claude_session_id, workspace_name, manually_renamed, first_prompt, title_generated, prompt_count, command FROM sessions ORDER BY created_at `) if err != nil { @@ -284,7 +294,7 @@ func (s *StateDB) LoadSessions() ([]*SessionRow, error) { var r SessionRow var createdAt, lastAccessed int64 var ack, manuallyRenamed, titleGenerated int - if err := rows.Scan(&r.ID, &r.Title, &r.ProjectPath, &r.Agent, &r.Status, &r.TmuxSession, &createdAt, &lastAccessed, &ack, &r.ClaudeSessionID, &r.WorkspaceName, &manuallyRenamed, &r.FirstPrompt, &titleGenerated, &r.PromptCount); err != nil { + if err := rows.Scan(&r.ID, &r.Title, &r.ProjectPath, &r.Agent, &r.Status, &r.TmuxSession, &createdAt, &lastAccessed, &ack, &r.ClaudeSessionID, &r.WorkspaceName, &manuallyRenamed, &r.FirstPrompt, &titleGenerated, &r.PromptCount, &r.Command); err != nil { debuglog.Logger.Error("failed to scan session row", "error", err) return nil, err } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 45a6b48b..8355c2be 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -4,7 +4,9 @@ import ( "context" "crypto/rand" "fmt" + "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -344,6 +346,64 @@ func (s *Session) PaneDeadInfo() (dead bool, exitStatus, exitSignal string, ok b return parts[0] == "1", parts[1], parts[2], true } +// CursorPosition holds the cursor location within a tmux pane. +type CursorPosition struct { + X int // 0-based column + Y int // 0-based row +} + +// PaneCursorPosition returns the cursor position within the pane. +func (s *Session) PaneCursorPosition() (CursorPosition, error) { + ctx, cancel := context.WithTimeout(context.Background(), captureTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "tmux", "list-panes", "-t", s.Name, + "-F", "#{cursor_x} #{cursor_y}").Output() + if err != nil { + return CursorPosition{}, fmt.Errorf("cursor position failed: %w", err) + } + var x, y int + if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%d %d", &x, &y); err != nil { + return CursorPosition{}, fmt.Errorf("parse cursor position: %w", err) + } + return CursorPosition{X: x, Y: y}, nil +} + +// PaneCurrentCommand returns the name of the process currently running in the pane. +// For a shell session at a prompt, this returns the shell name (e.g. "zsh", "bash"). +// When a command is running, this returns that command name (e.g. "make", "go"). +func (s *Session) PaneCurrentCommand() string { + out, err := exec.Command("tmux", "list-panes", "-t", s.Name, "-F", "#{pane_current_command}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// SetupShellExitHook injects a precmd/PROMPT_COMMAND hook into the shell session +// that writes the last exit code to the session's hook status file. +func (s *Session) SetupShellExitHook(sessionID string) { + hooksDir := shellExitHooksDir() + // Use a shell-agnostic approach: set PROMPT_COMMAND for bash, precmd for zsh. + // The setup command appends to precmd_functions (zsh) or PROMPT_COMMAND (bash), + // then clears the screen so the user sees a clean prompt. + // Single command: define hook + register + clear screen, all in one Enter press. + setup := fmt.Sprintf( + `eval '__bc_hook(){ local rc=$?; printf "{\"exit_code\":%%d}\n" $rc > %s/%s_exit.json; return $rc; }; if [ -n "$ZSH_VERSION" ]; then precmd_functions+=(__bc_hook); else PROMPT_COMMAND="__bc_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"; fi' && clear`, + hooksDir, sessionID, + ) + _ = s.SendLiteralKeys(setup) + _ = s.SendKeys("Enter") +} + +// shellExitHooksDir returns the hooks directory path. +func shellExitHooksDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), ".config", "fleet", "hooks") + } + return filepath.Join(home, ".config", "fleet", "hooks") +} + // Exists checks if the tmux session is alive. func (s *Session) Exists() bool { // Try cache first. diff --git a/internal/ui/app.go b/internal/ui/app.go index e076c0d7..c3e84c2a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -100,6 +100,7 @@ type ( previewMsg struct { sessionID string content string + cursor *tmux.CursorPosition // non-nil only in focus mode } loadSessionsMsg struct { sessions []*session.Session @@ -143,6 +144,7 @@ type ( discoveryMsg struct { items []discovery.Recent } + shellStatusDoneMsg struct{} // signals UI to re-render after shell status update ) func spinnerTickCmd() tea.Msg { @@ -227,8 +229,9 @@ type Home struct { // Focus mode (split view). focusMode bool controlClient *tmux.ControlClient - cachedSidebar string // cached sidebar render for focus mode - sidebarDirty bool // true when sidebar needs rebuild + cachedSidebar string // cached sidebar render for focus mode + sidebarDirty bool // true when sidebar needs rebuild + cursorCache map[string]*tmux.CursorPosition // session ID -> last known cursor pos // Filter. filterInput textinput.Model @@ -341,6 +344,7 @@ func NewHome(storage *session.StateDB, cfg *config.Config, version string, ident previewCache: make(map[string]string), previewCacheTime: make(map[string]time.Time), repoLastHotAt: make(map[string]time.Time), + cursorCache: make(map[string]*tmux.CursorPosition), filterInput: fi, cfg: cfg, version: version, @@ -739,9 +743,19 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return h, nil + case shellStatusDoneMsg: + // Shell status updated in background — force sidebar redraw. + h.sidebarDirty = true + return h, nil + case previewMsg: h.previewCache[msg.sessionID] = msg.content h.previewCacheTime[msg.sessionID] = time.Now() + if msg.cursor != nil { + h.cursorCache[msg.sessionID] = msg.cursor + } else { + delete(h.cursorCache, msg.sessionID) + } return h, nil case workspaceListMsg: @@ -898,14 +912,14 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if h.focusMode { return h, h.previewTick() // focus mode has its own faster tick } - var previewCmd tea.Cmd + var cmds []tea.Cmd if sel := h.selectedSession(); sel != nil && sel.IsAlive() { - previewCmd = h.fetchPreview(sel) - } - if previewCmd != nil { - return h, tea.Batch(previewCmd, h.previewTick()) + cmds = append(cmds, h.fetchPreview(sel)) } - return h, h.previewTick() + // Fast-poll shell session status (500ms vs 2s background worker). + cmds = append(cmds, h.updateShellSessionStatuses()) + cmds = append(cmds, h.previewTick()) + return h, tea.Batch(cmds...) case focusTickMsg: if !h.focusMode { @@ -1271,9 +1285,9 @@ func (h *Home) renderBody() string { b.WriteString(RenderBorderedPanelTopRight(sidebarInner, "Sessions", statusTitle, h.width, sidebarHeight, h.focusMode)) b.WriteString("\n\n") - s, content := h.selectedPreview() + s, content, cursor := h.selectedPreview() previewRepoInfo := h.repoInfoFromSnap(gitInfoSnap) - previewInner := RenderPreview(s, content, previewRepoInfo, innerW, previewHeight-2, h.focusMode) + previewInner := RenderPreview(s, content, previewRepoInfo, innerW, previewHeight-2, h.focusMode, cursor) previewInner = ensureExactHeight(previewInner, previewHeight-2) previewInner = ensureExactWidth(previewInner, innerW) previewTitle := BuildPreviewTitle(s, previewRepoInfo, h.focusMode, h.width-6) @@ -1281,19 +1295,10 @@ func (h *Home) renderBody() string { b.WriteString(RenderBorderedPanelFooter(previewInner, previewTitle, previewFooter, h.width, previewHeight, h.focusMode)) default: // dual gap := 1 // single-column gap between the two bordered panels - // Sidebar wants a ~target absolute width that's comfortable for long - // branch names and session titles — so on a Mac 14" (~150 cols) it's - // ~40%, on a wide monitor (~250 cols) it shrinks to ~25% so the - // preview keeps its share. Cap at 45% of total so it never dominates - // a small terminal; floor at 22 cols so the headers don't collapse. - const sidebarTargetCols = 65 - sidebarWidth := sidebarTargetCols - if cap := h.width * 45 / 100; sidebarWidth > cap { - sidebarWidth = cap - } - if sidebarWidth < 22 { - sidebarWidth = 22 - } + // Sidebar width is user-controllable via [ / ] (persisted as + // SidebarPct); sidebarWidth() converts the percentage to columns and + // guarantees the preview keeps a usable minimum. + sidebarWidth := h.sidebarWidth() previewWidth := h.width - sidebarWidth - gap sidebarInnerW := sidebarWidth - 2 @@ -1313,9 +1318,9 @@ func (h *Home) renderBody() string { h.sidebarDirty = false } - s, content := h.selectedPreview() + s, content, cursor := h.selectedPreview() previewRepoInfo := h.repoInfoFromSnap(gitInfoSnap) - previewInner := RenderPreview(s, content, previewRepoInfo, previewInnerW, innerH, h.focusMode) + previewInner := RenderPreview(s, content, previewRepoInfo, previewInnerW, innerH, h.focusMode, cursor) previewInner = ensureExactHeight(previewInner, innerH) previewInner = ensureExactWidth(previewInner, previewInnerW) previewTitle := BuildPreviewTitle(s, previewRepoInfo, h.focusMode, previewWidth-6) @@ -1618,6 +1623,20 @@ func (h *Home) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "right", "l": h.expandRepoAtCursor() return h, nil + case "[": + if h.layoutMode() == "dual" { + h.cfg.StepSidebarPct(-1) + _ = h.cfg.Save() + h.sidebarDirty = true + } + return h, nil + case "]": + if h.layoutMode() == "dual" { + h.cfg.StepSidebarPct(1) + _ = h.cfg.Save() + h.sidebarDirty = true + } + return h, nil case "a": // Instant session at current repo path. repoPath := h.resolveCurrentRepo() @@ -1735,6 +1754,28 @@ func (h *Home) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } h.slotAssignExpires = time.Now().Add(2 * time.Second) return h, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return slotAssignTimeoutMsg{} }) + case "x": + s := h.selectedSession() + if s == nil { + return h, nil + } + if s.ProjectPath == "" { + h.setError(fmt.Errorf("session has no project path")) + return h, nil + } + h.actionLog.Add("open shell", s.ProjectPath, true) + analytics.Track(analytics.EventCommandRun, nil) + projectPath := s.ProjectPath + dirName := filepath.Base(projectPath) + shell := session.NewSession("shell: "+dirName, projectPath) + shell.Command = "shell" + shell.ManuallyRenamed = true + return h, func() tea.Msg { + if err := shell.Start(); err != nil { + return sessionCreateResultMsg{err: err} + } + return sessionCreateResultMsg{session: shell} + } case "/": h.filterActive = true h.filterInput.Focus() @@ -2757,8 +2798,20 @@ func (h *Home) fetchPreviewFresh(s *session.Session) tea.Cmd { id := s.ID ts := s.GetTmuxSession() return func() tea.Msg { - content, _ := ts.CapturePaneFresh() - return previewMsg{sessionID: id, content: content} + // Run capture and cursor fetch in parallel to halve latency. + var content string + var cursor *tmux.CursorPosition + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if pos, err := ts.PaneCursorPosition(); err == nil { + cursor = &pos + } + }() + content, _ = ts.CapturePaneFresh() + wg.Wait() + return previewMsg{sessionID: id, content: content, cursor: cursor} } } @@ -3934,12 +3987,51 @@ func copyClaudeSettingsFile(srcRepo, dstRepo string) { } } +// updateShellSessionStatuses runs a fast status check on all shell sessions. +// Called from previewTick (500ms) for responsive running/idle detection. +func (h *Home) updateShellSessionStatuses() tea.Cmd { + h.workerMu.Lock() + var shells []*session.Session + for _, s := range h.sessions { + if s.IsShellSession() && s.IsAlive() { + shells = append(shells, s) + } + } + h.workerMu.Unlock() + + if len(shells) == 0 { + return nil + } + return func() tea.Msg { + for _, s := range shells { + oldStatus := s.GetStatus() + s.UpdateStatus() + newStatus := s.GetStatus() + if oldStatus != newStatus { + _ = h.storage.UpdateStatus(s.ID, string(newStatus)) + } + } + return shellStatusDoneMsg{} + } +} + func (h *Home) fetchPreview(s *session.Session) tea.Cmd { id := s.ID ts := s.GetTmuxSession() return func() tea.Msg { - content, _ := ts.CapturePane() - return previewMsg{sessionID: id, content: content} + var content string + var cursor *tmux.CursorPosition + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if pos, err := ts.PaneCursorPosition(); err == nil { + cursor = &pos + } + }() + content, _ = ts.CapturePane() + wg.Wait() + return previewMsg{sessionID: id, content: content, cursor: cursor} } } @@ -4206,6 +4298,23 @@ func (h *Home) jumpToSlot(slot int) (tea.Model, tea.Cmd) { return h, h.fetchPreviewForSelected() } +// sidebarWidth computes the sidebar width for dual-pane layout. +// It enforces a minimum of 20 columns for the sidebar and 30 for the preview. +func (h *Home) sidebarWidth() int { + sw := h.width * h.cfg.GetSidebarPct() / 100 + if sw < 20 { + sw = 20 + } + // Ensure preview gets at least 30 columns (width - sidebar - 3 for separator). + if h.width-sw-3 < 30 { + sw = h.width - 33 + } + if sw < 20 { + sw = 20 + } + return sw +} + func (h *Home) selectedSession() *session.Session { if h.cursor < 0 || h.cursor >= len(h.flatItems) || h.flatItems[h.cursor].IsRepoHeader { return nil @@ -4213,13 +4322,14 @@ func (h *Home) selectedSession() *session.Session { return h.flatItems[h.cursor].Session } -func (h *Home) selectedPreview() (*session.Session, string) { +func (h *Home) selectedPreview() (*session.Session, string, *tmux.CursorPosition) { s := h.selectedSession() if s == nil { - return nil, "" + return nil, "", nil } content := h.previewCache[s.ID] - return s, content + cursor := h.cursorCache[s.ID] + return s, content, cursor } // repoInfoFromSnap returns repo info for the selected session using a snapshot diff --git a/internal/ui/keybindings.go b/internal/ui/keybindings.go index c1a87c03..f5d1fa93 100644 --- a/internal/ui/keybindings.go +++ b/internal/ui/keybindings.go @@ -19,6 +19,7 @@ var allKeyBindings = []KeyBinding{ {Key: "k / ↑", Desc: "Move up", Section: "nav"}, {Key: "PgDn", Desc: "Page down", Section: "nav"}, {Key: "PgUp", Desc: "Page up", Section: "nav"}, + {Key: "[ / ]", Desc: "Resize sidebar", Section: "nav"}, // Session actions. {Key: "Enter", BarKey: "⏎", BarDesc: "Open", Desc: "Attach / toggle group", Section: "session"}, @@ -40,6 +41,7 @@ var allKeyBindings = []KeyBinding{ {Key: "p", BarKey: "p", BarDesc: "PR", Desc: "Open PR in browser", Section: "session"}, {Key: "Y", BarKey: "Y", BarDesc: "Approve", Desc: "Quick approve permission", Section: "session"}, {Key: "b", BarKey: "b", BarDesc: "Branch", Desc: "Switch git branch", Section: "session"}, + {Key: "x", BarKey: "x", BarDesc: "Shell", Desc: "Open shell in session directory", Section: "session"}, {Key: "/", BarKey: "/", BarDesc: "Filter", Desc: "Filter sessions", Section: "session"}, {Key: "0-9", Desc: "Jump to slot (double-tap to attach)", Section: "session"}, {Key: "Alt+0-9", Desc: "Bind/unbind slot (re-press same slot clears it)", Section: "session"}, From dd89c9bc0a15b1da0948281f47f47ed52ce87704 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sun, 22 Mar 2026 00:10:59 +0200 Subject: [PATCH 3/7] docs: add changelog entries and changelog rules to CLAUDE.md - Add shell sessions, sidebar resize, and cursor tracking to Unreleased - Document changelog requirement in CLAUDE.md so CI check is not missed --- CHANGELOG.md | 3 +++ changelog/unreleased/shell-sessions.md | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 changelog/unreleased/shell-sessions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ae7936..a702889b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,9 @@ Or run `fleet` directly — the migration shim handles config moves, tmux sessio - Agent team status detection: sub-agent permission prompts and "Waiting for team lead approval" now correctly show as waiting - Command palette (`:` or `Ctrl+P`) — fuzzy-searchable list of all actions with shortcut hints, plus "Reload All Sessions" for bulk restart of dead/error sessions - Terminal environment and rendering stats in bug reports to help diagnose scroll/rendering issues +- Resizable sidebar with `[` / `]` keybindings +- `sidebar_pct` config option for persistent sidebar width +- Cursor position tracking in preview pane ### Improved diff --git a/changelog/unreleased/shell-sessions.md b/changelog/unreleased/shell-sessions.md new file mode 100644 index 00000000..1ae03b31 --- /dev/null +++ b/changelog/unreleased/shell-sessions.md @@ -0,0 +1,5 @@ +--- +type: added +--- + +Shell sessions: press `x` to open a terminal in any session's working directory, with real-time status indicators (green dot for running command, dim circle for idle, red X for non-zero exit). From 6e9274f45bb4f1f966c52f8be4ac5383e2f577d4 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sun, 22 Mar 2026 00:20:40 +0200 Subject: [PATCH 4/7] chore: add copilot-instructions.md as symlink to CLAUDE.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 00000000..949a29f1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file From b77101efdf8dff05a7f26915263932f1c9b0ea20 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sun, 22 Mar 2026 00:35:55 +0200 Subject: [PATCH 5/7] fix: address PR review feedback - Downgrade cursor-not-rendered log from WARN to DEBUG - Log cfg.Save() errors on sidebar resize - Log storage.UpdateStatus errors in shell status poller - Add timeout + error logging to PaneCurrentCommand() - Fix misleading comments (Command field, shell-agnostic, cursor) - Clear stale exit code file on shell session start/restart - Ensure hooks dir exists via mkdir -p in shell setup snippet --- internal/session/session.go | 7 ++++++- internal/tmux/tmux.go | 11 +++++++---- internal/ui/app.go | 14 ++++++++++---- internal/ui/preview.go | 2 +- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 3200efd0..4f3e52ff 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -58,7 +58,7 @@ type Session struct { TitleGenerated bool PromptCount int ForkFromID string // Transient: if set, start with --resume --fork-session (cleared after start) - Command string // If set, run this shell command instead of Claude. + Command string // Non-empty marks this as a shell session (opens user's $SHELL instead of Claude). ShellExitCode int // Last exit code from shell session (0 = success). hookStatus string @@ -143,6 +143,9 @@ func (s *Session) Start() error { if s.IsShellSession() { // Shell session: start with user's shell, no command sent. cmd = "" + // Clear stale exit code from previous run. + s.ShellExitCode = 0 + _ = os.Remove(shellExitFilePath(s.ID)) } else { cmd = s.buildAgentCmd() } @@ -335,6 +338,8 @@ func (s *Session) Restart() error { var cmd string if s.IsShellSession() { cmd = "" + s.ShellExitCode = 0 + _ = os.Remove(shellExitFilePath(s.ID)) } else { cmd = s.buildAgentCmd() } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 8355c2be..443978e6 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -372,8 +372,11 @@ func (s *Session) PaneCursorPosition() (CursorPosition, error) { // For a shell session at a prompt, this returns the shell name (e.g. "zsh", "bash"). // When a command is running, this returns that command name (e.g. "make", "go"). func (s *Session) PaneCurrentCommand() string { - out, err := exec.Command("tmux", "list-panes", "-t", s.Name, "-F", "#{pane_current_command}").Output() + ctx, cancel := context.WithTimeout(context.Background(), captureTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "tmux", "list-panes", "-t", s.Name, "-F", "#{pane_current_command}").Output() if err != nil { + debuglog.Logger.Error("tmux PaneCurrentCommand failed", "session", s.Name, "err", err) return "" } return strings.TrimSpace(string(out)) @@ -383,13 +386,13 @@ func (s *Session) PaneCurrentCommand() string { // that writes the last exit code to the session's hook status file. func (s *Session) SetupShellExitHook(sessionID string) { hooksDir := shellExitHooksDir() - // Use a shell-agnostic approach: set PROMPT_COMMAND for bash, precmd for zsh. + // For supported shells, install a hook: use precmd_functions for zsh and PROMPT_COMMAND for bash. // The setup command appends to precmd_functions (zsh) or PROMPT_COMMAND (bash), // then clears the screen so the user sees a clean prompt. // Single command: define hook + register + clear screen, all in one Enter press. setup := fmt.Sprintf( - `eval '__bc_hook(){ local rc=$?; printf "{\"exit_code\":%%d}\n" $rc > %s/%s_exit.json; return $rc; }; if [ -n "$ZSH_VERSION" ]; then precmd_functions+=(__bc_hook); else PROMPT_COMMAND="__bc_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"; fi' && clear`, - hooksDir, sessionID, + `mkdir -p '%s' && eval '__bc_hook(){ local rc=$?; printf "{\"exit_code\":%%d}\n" $rc > %s/%s_exit.json; return $rc; }; if [ -n "$ZSH_VERSION" ]; then precmd_functions+=(__bc_hook); else PROMPT_COMMAND="__bc_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"; fi' && clear`, + hooksDir, hooksDir, sessionID, ) _ = s.SendLiteralKeys(setup) _ = s.SendKeys("Enter") diff --git a/internal/ui/app.go b/internal/ui/app.go index c3e84c2a..be4bd45a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -100,7 +100,7 @@ type ( previewMsg struct { sessionID string content string - cursor *tmux.CursorPosition // non-nil only in focus mode + cursor *tmux.CursorPosition // tmux cursor position, if available } loadSessionsMsg struct { sessions []*session.Session @@ -1626,14 +1626,18 @@ func (h *Home) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "[": if h.layoutMode() == "dual" { h.cfg.StepSidebarPct(-1) - _ = h.cfg.Save() + if err := h.cfg.Save(); err != nil { + debuglog.Logger.Error("failed to save config after sidebar resize", "err", err) + } h.sidebarDirty = true } return h, nil case "]": if h.layoutMode() == "dual" { h.cfg.StepSidebarPct(1) - _ = h.cfg.Save() + if err := h.cfg.Save(); err != nil { + debuglog.Logger.Error("failed to save config after sidebar resize", "err", err) + } h.sidebarDirty = true } return h, nil @@ -4008,7 +4012,9 @@ func (h *Home) updateShellSessionStatuses() tea.Cmd { s.UpdateStatus() newStatus := s.GetStatus() if oldStatus != newStatus { - _ = h.storage.UpdateStatus(s.ID, string(newStatus)) + if err := h.storage.UpdateStatus(s.ID, string(newStatus)); err != nil { + debuglog.Logger.Error("failed to persist shell session status", "session_id", s.ID, "status", newStatus, "err", err) + } } } return shellStatusDoneMsg{} diff --git a/internal/ui/preview.go b/internal/ui/preview.go index 7ec7785e..3b59e170 100644 --- a/internal/ui/preview.go +++ b/internal/ui/preview.go @@ -98,7 +98,7 @@ func RenderPreview(s *session.Session, content string, repoInfo *git.RepoInfo, w } if cursor != nil && !cursorRendered { - debuglog.Logger.Warn("cursor NOT rendered", + debuglog.Logger.Debug("cursor NOT rendered", "cursor_x", cursor.X, "cursor_y", cursor.Y, "total_lines", len(lines), "start", start, "content_height", contentHeight) From df439f8fce415325b6988327773b424f1a5cac18 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Mon, 13 Apr 2026 01:36:36 +0300 Subject: [PATCH 6/7] fix: address shell session bugs from PR review - RespawnPane: skip empty command arg so tmux spawns default shell - ShellExitCode: eliminate data race with proper mutex scoping - deleteSession: clean up _exit.json files to prevent leak --- internal/session/session.go | 25 +++++++++++++++++-------- internal/tmux/tmux.go | 4 +++- internal/ui/app.go | 10 ++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 4f3e52ff..81675008 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -144,8 +144,10 @@ func (s *Session) Start() error { // Shell session: start with user's shell, no command sent. cmd = "" // Clear stale exit code from previous run. + s.mu.Lock() s.ShellExitCode = 0 - _ = os.Remove(shellExitFilePath(s.ID)) + s.mu.Unlock() + _ = os.Remove(ShellExitFilePath(s.ID)) } else { cmd = s.buildAgentCmd() } @@ -338,8 +340,10 @@ func (s *Session) Restart() error { var cmd string if s.IsShellSession() { cmd = "" + s.mu.Lock() s.ShellExitCode = 0 - _ = os.Remove(shellExitFilePath(s.ID)) + s.mu.Unlock() + _ = os.Remove(ShellExitFilePath(s.ID)) } else { cmd = s.buildAgentCmd() } @@ -375,6 +379,10 @@ func (s *Session) RespawnClaude() error { var cmd string if s.IsShellSession() { cmd = "" + s.mu.Lock() + s.ShellExitCode = 0 + s.mu.Unlock() + _ = os.Remove(ShellExitFilePath(s.ID)) } else { cmd = s.buildAgentCmd() } @@ -909,21 +917,22 @@ func (s *Session) updateShellStatus(oldStatus Status, log *slog.Logger) { isShellPrompt := cmd == userShell || currentCmd == "" // Read exit code from hook file if available. - exitFile := shellExitFilePath(s.ID) + var exitCode int + exitFile := ShellExitFilePath(s.ID) if data, err := os.ReadFile(exitFile); err == nil { var result struct { ExitCode int `json:"exit_code"` } if err := json.Unmarshal(data, &result); err == nil { - s.mu.Lock() - s.ShellExitCode = result.ExitCode - s.mu.Unlock() + exitCode = result.ExitCode } } s.mu.Lock() defer s.mu.Unlock() + s.ShellExitCode = exitCode + if isShellPrompt { // At shell prompt — command finished (or no command run yet). if s.ShellExitCode != 0 { @@ -942,8 +951,8 @@ func (s *Session) updateShellStatus(oldStatus Status, log *slog.Logger) { } } -// shellExitFilePath returns the path to the shell exit code file for a session. -func shellExitFilePath(sessionID string) string { +// ShellExitFilePath returns the path to the shell exit code file for a session. +func ShellExitFilePath(sessionID string) string { home, err := os.UserHomeDir() if err != nil { return filepath.Join(os.TempDir(), ".config", "brizz-code", "hooks", sessionID+"_exit.json") diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 443978e6..dadc1870 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -281,7 +281,9 @@ func (s *Session) RespawnPane(command string, env ...string) error { for _, e := range env { args = append(args, "-e", e) } - args = append(args, command) + if command != "" { + args = append(args, command) + } cmd := exec.Command("tmux", args...) if err := cmd.Run(); err != nil { debuglog.Logger.Error("tmux respawn failed", "session", s.Name, "err", err) diff --git a/internal/ui/app.go b/internal/ui/app.go index be4bd45a..ded67632 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2904,6 +2904,16 @@ func (h *Home) deferDelete(msg sessionDeleteMsg) (tea.Model, tea.Cmd) { } } + // Remove hook status file. + if err := os.Remove(filepath.Join(hooks.GetHooksDir(), msg.id+".json")); err != nil && !os.IsNotExist(err) { + debuglog.Logger.Error("failed to remove hook status file", "id", msg.id, "err", err) + } + + // Remove shell exit code file (no-op for non-shell sessions). + if err := os.Remove(session.ShellExitFilePath(msg.id)); err != nil && !os.IsNotExist(err) { + debuglog.Logger.Error("failed to remove shell exit file", "id", msg.id, "err", err) + } + // Remove from in-memory session list. var remaining []*session.Session for _, sess := range h.sessions { From acae5609f204b34d33cb42515e11f431d88556d0 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Fri, 29 May 2026 17:38:47 +0300 Subject: [PATCH 7/7] =?UTF-8?q?fix(shell):=20address=20hayke102=20review?= =?UTF-8?q?=20=E2=80=94=20quote=20paths,=20dedupe=20hooks=20dir,=20target?= =?UTF-8?q?=20active=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quote redirect path in SetupShellExitHook (%q) so home dirs with spaces work - Dedupe shellExitHooksDir() into internal/hooks.GetHooksDir() (drift risk) - Target active pane (:0.0) in PaneCursorPosition for correct split-pane coords - Document the x shell-session keybinding in CLAUDE.md - Rename injected shell hook __bc_hook -> __fleet_hook --- CLAUDE.md | 2 +- internal/tmux/tmux.go | 21 +++++++-------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9dc77cc4..09ae6ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Status: Running, Waiting, Finished, Idle, Error, Starting - Status icons: ● (running/finished), ◐ (waiting), ○ (idle/starting), ✕ (error) - Agent glyph: each session row shows a dim, monochrome per-agent sigil between the status dot and the title — `✻` Claude, `◇` Codex (`agentGlyph` + `AgentGlyphStyle` in `sidebar.go`/`styles.go`); both are width-1 glyphs from well-covered Unicode blocks (Dingbats / Geometric Shapes — same block as the status dots) so they stay aligned in base mono fonts; identity is carried by shape so the status dot keeps sole ownership of color; empty/legacy `Agent` falls back to Claude -- Keybindings: j/k nav, [ / ] resize sidebar, Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch), F fork to worktree (Claude-only), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin), u undo delete (5s window), r restart, R rename, e editor, p open PR in browser, Y quick approve (waiting sessions), / filter, Ctrl+K command palette, S settings, ! bug report/diagnostics, ? help, q quit +- Keybindings: j/k nav, [ / ] resize sidebar, Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch), F fork to worktree (Claude-only), x shell session (open terminal in session directory), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin), u undo delete (5s window), r restart, R rename, e editor, p open PR in browser, Y quick approve (waiting sessions), / filter, Ctrl+K command palette, S settings, ! bug report/diagnostics, ? help, q quit - Session hotkeys (RTS-style): `Alt+0-9` (or `=` then digit) binds the selected session to a slot; re-pressing `Alt+` on a session already in slot N unbinds; `==` then digit clears any slot; plain `0-9` jumps to the bound session (double-tap within 400ms also attaches); `[N]` badge in sidebar marks bound sessions; bindings persist in SQLite `slot_bindings` table (FK cascade on session delete) - Command palette (Ctrl+K): renders as an overlay over the sidebar/preview (not a full-screen takeover); fuzzy-searches commands plus every repo/worktree currently in the sidebar (name, branch, full path all matched); picking a repo/worktree jumps the sidebar cursor to that header (auto-expand if collapsed); palette-only commands include "Reload All Sessions" (restarts all dead/error sessions). For a native Cmd+K feel on macOS, map Cmd+K → Ctrl+K in your terminal prefs (iTerm2: Profiles → Keys → Key Mappings → +; Ghostty: `keybind = cmd+k=text:\x0b`). - Undo delete: `u` key restores last deleted session within 5s window (stacked — multiple deletes each undoable). Tmux kept alive during window for full restore. diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index dadc1870..95a80d6b 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "fmt" - "os" "os/exec" "path/filepath" "strings" @@ -12,6 +11,7 @@ import ( "time" "github.com/brizzai/fleet/internal/debuglog" + "github.com/brizzai/fleet/internal/hooks" "golang.org/x/sync/singleflight" ) @@ -358,7 +358,7 @@ type CursorPosition struct { func (s *Session) PaneCursorPosition() (CursorPosition, error) { ctx, cancel := context.WithTimeout(context.Background(), captureTimeout) defer cancel() - out, err := exec.CommandContext(ctx, "tmux", "list-panes", "-t", s.Name, + out, err := exec.CommandContext(ctx, "tmux", "list-panes", "-t", s.Name+":0.0", "-F", "#{cursor_x} #{cursor_y}").Output() if err != nil { return CursorPosition{}, fmt.Errorf("cursor position failed: %w", err) @@ -387,28 +387,21 @@ func (s *Session) PaneCurrentCommand() string { // SetupShellExitHook injects a precmd/PROMPT_COMMAND hook into the shell session // that writes the last exit code to the session's hook status file. func (s *Session) SetupShellExitHook(sessionID string) { - hooksDir := shellExitHooksDir() + hooksDir := hooks.GetHooksDir() + hookFile := filepath.Join(hooksDir, sessionID+"_exit.json") // For supported shells, install a hook: use precmd_functions for zsh and PROMPT_COMMAND for bash. // The setup command appends to precmd_functions (zsh) or PROMPT_COMMAND (bash), // then clears the screen so the user sees a clean prompt. // Single command: define hook + register + clear screen, all in one Enter press. + // Paths are interpolated with %q so home dirs containing spaces stay intact. setup := fmt.Sprintf( - `mkdir -p '%s' && eval '__bc_hook(){ local rc=$?; printf "{\"exit_code\":%%d}\n" $rc > %s/%s_exit.json; return $rc; }; if [ -n "$ZSH_VERSION" ]; then precmd_functions+=(__bc_hook); else PROMPT_COMMAND="__bc_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"; fi' && clear`, - hooksDir, hooksDir, sessionID, + `mkdir -p %q && eval '__fleet_hook(){ local rc=$?; printf "{\"exit_code\":%%d}\n" $rc > %q; return $rc; }; if [ -n "$ZSH_VERSION" ]; then precmd_functions+=(__fleet_hook); else PROMPT_COMMAND="__fleet_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"; fi' && clear`, + hooksDir, hookFile, ) _ = s.SendLiteralKeys(setup) _ = s.SendKeys("Enter") } -// shellExitHooksDir returns the hooks directory path. -func shellExitHooksDir() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(os.TempDir(), ".config", "fleet", "hooks") - } - return filepath.Join(home, ".config", "fleet", "hooks") -} - // Exists checks if the tmux session is alive. func (s *Session) Exists() bool { // Try cache first.