Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
33 changes: 33 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), 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+<N>` 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.
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ vet:
go vet ./...

install: build
cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/
install -m 755 $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Create ~/.local/bin before install to avoid first-run failure.

install will fail if the destination directory doesn’t exist on a fresh setup.

Suggested patch
 install: build
+	mkdir -p ~/.local/bin
 	install -m 755 $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
install -m 755 $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
install: build
mkdir -p ~/.local/bin
install -m 755 $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` at line 43, The install rule currently assumes ~/.local/bin exists;
ensure the directory is created before running the install command by adding a
preparatory step that creates the target directory (e.g., use mkdir -p or
install -d for ~/.local/bin or $(HOME)/.local/bin) prior to the install -m 755
$(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY) invocation so the install won't
fail on a fresh system.


setup:
pre-commit install
5 changes: 5 additions & 0 deletions changelog/unreleased/shell-sessions.md
Original file line number Diff line number Diff line change
@@ -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).
66 changes: 66 additions & 0 deletions docs/features/shell-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Shell Sessions

Open a plain terminal in any session's working directory without leaving brizz-code.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update legacy brizz-code references to fleet paths/naming.

This doc still points users to the old app/config namespace, which can send them to the wrong location when troubleshooting shell status files.

Suggested patch
-Open a plain terminal in any session's working directory without leaving brizz-code.
+Open a plain terminal in any session's working directory without leaving fleet.
@@
-**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/<session_id>_exit.json`. The status poller reads this file to detect non-zero exit codes.
+**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/fleet/hooks/<session_id>_exit.json`. The status poller reads this file to detect non-zero exit codes.

Based on learnings, hook status files are stored under ~/.config/fleet/hooks/{session_id}.json and product naming uses fleet.

Also applies to: 42-42

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/features/shell-sessions.md` at line 3, Update
docs/features/shell-sessions.md to replace legacy "brizz-code" naming and paths
with "fleet" equivalents: change any textual references of the product name
"brizz-code" to "fleet" and update hook status file locations from the old path
to the new "~/.config/fleet/hooks/{session_id}.json" pattern (mention
{session_id} as the placeholder used in the doc). Ensure any explanatory lines
about where to look for hook status files and troubleshooting steps use the new
"fleet" namespace and the exact path pattern so users are directed correctly.

Source: Learnings


## 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: <dirname>`.

## 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/<session_id>_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.
1 change: 1 addition & 0 deletions internal/analytics/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
EventPROpened = "pr_opened"
EventUndoUsed = "undo_used"
EventForkSession = "fork_session"
EventCommandRun = "command_run"

// Workspaces & repos.
EventWorkspaceCreated = "workspace_created"
Expand Down
48 changes: 48 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
145 changes: 144 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -201,6 +340,7 @@ func TestConfigJSONRoundTrip(t *testing.T) {
AutoUpdate: &autoUpdate,
CopyClaudeSettings: &copySettings,
EnterMode: "split",
SidebarPct: &sidebarPct,
}

data, err := json.Marshal(original)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading