Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,8 @@ Available action names:
`resume_interrupted`, `view_plan`, `copy_id`, `copy_path`,
`copy_resume_command`, `copy_preview`, `expand_collapse_all`,
`scan_work_status`, `export`, `note`, `shift_up`, `shift_down`, `view_switch`,
`open_file`, `open_dir`, `timeline`, `compare`, `cmd_palette`.
`open_file`, `open_dir`, `open_ref`, `timeline`, `compare`, `git_status`,
`cmd_palette`.

## Themes

Expand Down
2 changes: 1 addition & 1 deletion cmd/dispatch/launch_overrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestParseOpenArgs_Overrides(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
id, mode, _, _, ov, err := parseOpenArgs(tc.args)
id, mode, _, _, _, ov, err := parseOpenArgs(tc.args)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got id=%q mode=%q", id, mode)
Expand Down
7 changes: 0 additions & 7 deletions cmd/dispatch/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,13 +468,6 @@ func TestPrintUsage_Output(t *testing.T) {
close(readDone)
}()

var buf bytes.Buffer
readDone := make(chan struct{})
go func() {
_, _ = io.Copy(&buf, r)
close(readDone)
}()

printUsage()

_ = w.Close()
Expand Down
4 changes: 2 additions & 2 deletions cmd/dispatch/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool,
stdin = true
case arg == "--mode" || arg == "-m":
if i+1 >= len(rest) {
return "", "", false, false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane")
return "", "", false, false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane")
}
mode = rest[i+1]
i++
Expand All @@ -243,7 +243,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool,
case arg == "--print":
printCmd = true
case strings.HasPrefix(arg, "-"):
return "", "", false, false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg)
return "", "", false, false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg)
default:
positionals = append(positionals, arg)
}
Expand Down
7 changes: 7 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@
- Behavior: Opens the selected session's linked reference on github.com in the default browser. Chooses the pull request first, then the issue, then the commit. Only http/https URLs are opened.
- Condition: Only when the selected session has a repository and at least one PR, issue, or commit reference

20h. **i** → Git Status of Session Folder
- File: internal\tui\keys.go
- Code: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "git status"))
- Handler: internal\tui\model.go (handleGitStatus / handleGitStatusMsg)
- Behavior: Opens a modal overlay showing the git status of the folder the selected session (or folder-pivot row) is mapped to: current branch and upstream, the standard push/pull stats (commits ahead to push, behind to pull), and working-tree counts (staged, modified, untracked, deleted, conflicts), plus a scrollable list of changed files. Inside the overlay: Esc or i closes it, ↑/↓ (or PgUp/PgDn) scroll the file list, and c copies a plain-text summary to the clipboard. Git commands run under a bounded timeout so the UI never blocks; non-repo, missing, detached-HEAD, and no-upstream folders are reported without error. The same push/pull stats also appear inline on each session row (a compact `↑N ↓N ●` segment at wider terminals) and in the preview pane's git section (branch → upstream, push/pull, and per-category change counts).
- Condition: Only when a session or folder row with a working directory is selected

21. **PgUp (Page Up)** → Preview Panel Scroll Up
- File: internal\tui\keys.go (line 85)
- Code: key.NewBinding(key.WithKeys("pgup"))
Expand Down
130 changes: 130 additions & 0 deletions docs/specs/git-status/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
issue: https://github.com/jongio/dispatch/pull/316
author: @jongio
status: shipped
---

# Git Status Overlay

## Problem

Every dispatch session is mapped to a working directory (`Cwd`). The session
list already surfaces a single-glyph git badge (dirty / untracked / ahead /
behind / missing), but that badge collapses everything into one state and hides
the numbers that actually matter when you are deciding what to do next. If a
folder is both 2 commits ahead and 3 behind with 4 modified files, the badge
just shows "dirty" or "ahead" — you cannot tell how far ahead you are, whether
you owe a `push`, whether you need to `pull` first, or how much uncommitted work
is sitting in the tree.

Today the only way to answer "what is the git state of this session's folder?"
is to leave dispatch, `cd` into the directory, and run `git status`. That breaks
the triage flow the TUI is built for, especially when scanning many parallel
agent sessions.

## Goals

- View detailed git status for the selected session's folder without leaving the TUI
- Show the standard push/pull stats: commits **ahead** (to push) and **behind**
(to pull) relative to the upstream branch, plus the branch and upstream names
- Show working-tree counts: staged, modified, untracked, deleted, and conflicts,
with an explicit "clean" indicator when there is nothing to report
- List the changed files with their short status codes, scrollable for large trees
- Handle non-repo, missing-directory, detached-HEAD, and no-upstream cases
gracefully — never crash and never block the UI
- Reachable both by a dedicated keybinding and from the command palette
- Surface the push/pull stats **inline** on each session row and in the
**preview pane**, not only in the on-demand overlay

## Non-Goals

- Performing git actions (push / pull / commit / stage) — this is read-only status
- A full diff viewer or per-file hunk display (short status codes only)
- Live auto-refreshing of the overlay while it is open (it is a point-in-time snapshot)
- Replacing the existing session-list git badge (this complements it)
- Multi-session/aggregate git status (one folder at a time, the selected row)

## Extension: inline row + preview pane (follow-up)

The overlay alone requires a keypress per session. To make the push/pull state
scannable at a glance across many sessions, the git info is also surfaced in two
always-visible places:

- **Session row (inline):** a compact, colored `↑N ↓N ●` segment — commits ahead
(to push), behind (to pull), and a dirty marker when the working tree has
changes. Shown as a fixed-width, width-gated column so alignment holds and
narrow terminals are not crowded; the existing single-glyph badge stays.
- **Preview pane:** a full git section under the Branch field — branch → upstream,
push/pull counts, and per-category working-tree counts (or a clean indicator).

**Design consequence:** the inline row and preview both need the *detailed*
`GitStatus` for every visible session, not just the collapsed badge enum. Rather
than scanning twice, `DetectGitStatus` becomes the single git-scanning entry
point: a new `GitStatus.State()` derives the badge `GitState` enum, and
`ScanGitStatuses` replaces the old enum-only `ScanGitStates`/`DetectGitState`
(one `git status --porcelain=v2 --branch` per session instead of up to three
commands). All existing badge rendering, git-dirty/missing filters, and the
`gitStateMap` continue to work off the derived enum.

## Solution

Add a modal **Git Status overlay**, following the exact pattern already used by
the Compare (`D`) and Command Palette (`:`) overlays: a dedicated `viewState`,
a component that owns rendering + scrolling, an async command that gathers the
data off the UI thread, and a message that flips the model into the overlay
state.

**Data layer (`internal/platform/gitstate.go`).** The existing file already runs
bounded git commands for the collapsed badge. Add a richer sibling:

- A `GitStatus` struct carrying `Exists`, `IsRepo`, `Branch`, `Upstream`,
`Ahead`, `Behind`, per-category counts (`Staged`, `Modified`, `Untracked`,
`Deleted`, `Conflicts`), a `Clean` flag, and a capped slice of changed files
(short `XY` code + path).
- `DetectGitStatus(dir)` runs a single `git status --porcelain=v2 --branch`
invocation under the existing 2s context timeout. One command yields the
branch header, upstream, ahead/behind counts, and every changed entry, which
keeps the on-disk work and the parse atomic and consistent.
- A pure `parseGitStatusV2(output)` helper does the parsing so it can be unit
tested without a live repo. `DetectGitStatus` is the thin exec wrapper around it.

**Component (`internal/tui/components/gitstatusview.go`).** A `GitStatusView`
mirroring `CompareView`: `SetStatus`, `SetSize`, `ScrollUp/Down`, `View()`, and
`PlainText()` for clipboard. It renders labelled rows (path, branch → upstream,
push/pull, the working-tree counts) and a scrollable changed-files list, reusing
the existing git colour styles (`GitAheadStyle`, `GitBehindStyle`, etc.).

**Model wiring (`internal/tui`).** A new `stateGitStatusView`, a `gitStatusView`
field, a `gitStatusMsg`, a `showGitStatusCmd` that resolves the selected
session's `Cwd` (or the folder-pivot row's cwd) and runs `DetectGitStatus`
asynchronously, and a `GitStatus` keybinding on the free lowercase key **`i`**
("git info"). The overlay's key handling (esc / ↑↓ / copy) and resize follow the
`stateCompareView` cases. A palette entry and the demo-mode synthetic status
(gated by the existing `DISPATCH_DEMO_GIT_STATES` env var) keep parity with the
badge feature.

## Alternatives Considered

- **Inline section in the preview pane** instead of an overlay. Rejected: the
preview is already dense with conversation/plan content, the request is to
"easily see" status on demand, and an overlay matches the Compare/Timeline
precedent and is independently scrollable.
- **Reusing/extending the existing `GitState` enum.** Rejected: the enum is a
single collapsed value by design (drives the one-glyph badge). Push/pull stats
need real counts, so a parallel richer type is cleaner than overloading the enum.
- **Multiple git subprocesses** (`rev-parse`, `rev-list`, `status`) like the
badge path. Rejected in favour of one `--porcelain=v2 --branch` call: fewer
process spawns under the timeout and a single consistent snapshot.

## Risks & Rabbit Holes

- **Porcelain v2 parsing**: the `2` (rename/copy) and `u` (unmerged) record
shapes differ from ordinary `1` records. Parse defensively by field index and
treat the path as the remainder; do not over-model rename scoring.
- **No upstream / detached HEAD**: `branch.upstream` and `branch.ab` lines are
simply absent — represent "no upstream" explicitly rather than defaulting to
0/0 as if synced.
- **Unbounded file lists**: cap the stored changed-files slice so a giant tree
cannot blow up memory or the render; the overlay scrolls through what is kept.
- **Blocking the UI**: all git work must stay behind the context timeout and run
inside the async `tea.Cmd`, never on the update path.
Loading