diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ad7a674..d3d0fd0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: '1.22'
+ go-version-file: go.mod
cache: true
- name: gofmt
@@ -36,3 +36,53 @@ jobs:
# The plugin entry point is shell; catch syntax errors before users do.
- name: shellcheck kaku-tab.tmux
run: bash -n kaku-tab.tmux
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - uses: golangci/golangci-lint-action@v9
+ with:
+ # Pinned: an unpinned linter turns an unrelated upstream release into
+ # a red build on a PR that changed nothing.
+ version: v2.12.2
+
+ coverage:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: test with coverage
+ run: go test -covermode=atomic -coverprofile=cover.out ./...
+
+ # Thresholds live in .testcoverage.yml, set just under where the tree
+ # stands so this is a ratchet rather than a wall.
+ - uses: vladopajic/go-test-coverage@v2
+ with:
+ config: ./.testcoverage.yml
+
+ # The release config is only exercised on a tag push, which is the worst
+ # possible time to discover it no longer parses.
+ goreleaser-check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ - uses: goreleaser/goreleaser-action@v6
+ with:
+ version: '~> v2'
+ args: check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 72e144f..b42e5e2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,26 +8,27 @@ permissions:
contents: write
jobs:
- build:
+ goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-go@v5
with:
- go-version: '1.22'
+ # goreleaser builds the changelog from git history, which a shallow
+ # clone does not have.
+ fetch-depth: 0
- - name: Build binaries
- run: |
- mkdir -p dist
- for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
- os=${target%/*}; arch=${target#*/}
- GOOS=$os GOARCH=$arch go build \
- -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \
- -o "dist/kaku-tab-$os-$arch" ./cmd/kaku-tab
- done
- cd dist && sha256sum * > SHA256SUMS
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
- - uses: softprops/action-gh-release@v2
+ - uses: goreleaser/goreleaser-action@v6
with:
- files: dist/*
- generate_release_notes: true
+ version: '~> v2'
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Optional. Without it the release still publishes and the Homebrew
+ # cask is skipped — see the skip_upload guard in .goreleaser.yaml.
+ # Needs a PAT with write access to dsaad68/homebrew-tap.
+ HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 6150bd8..dd2f907 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
bin/
dist/
+cover.out
# Local agent/editor state
.claude/settings.local.json
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..e4459c1
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,54 @@
+version: "2"
+
+linters:
+ default: none
+ enable:
+ - errcheck
+ - govet
+ - ineffassign
+ - staticcheck
+ - unused
+ - bodyclose
+ - copyloopvar
+ - misspell
+ - nilerr
+ - revive
+ - unconvert
+
+ settings:
+ errcheck:
+ # A deliberate `_ =` is how this codebase marks "best effort, and the
+ # caller cannot do anything useful with the failure" — reaping a
+ # satellite, focusing a pane. Flagging those would just add noise.
+ check-blank: false
+ revive:
+ # Deliberately without the `exported` rule. This codebase comments why
+ # something is the way it is, not what it is; that rule would demand a
+ # line of restated signature above every thin tmux wrapper and every
+ # bubbletea interface method, and a gate full of noise is a gate people
+ # learn to skip.
+ rules:
+ - name: indent-error-flow
+ - name: var-declaration
+ - name: errorf
+ - name: context-as-argument
+ - name: increment-decrement
+ - name: unreachable-code
+ staticcheck:
+ checks: ['all']
+
+ exclusions:
+ generated: lax
+ rules:
+ # Test helpers are allowed to be undocumented and to ignore errors.
+ - path: _test\.go
+ linters: [errcheck, revive]
+
+formatters:
+ enable:
+ - gofmt
+
+issues:
+ # A lint gate that only reports the first few problems is not a gate.
+ max-issues-per-linter: 0
+ max-same-issues: 0
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 0000000..aee622c
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,85 @@
+# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
+version: 2
+
+project_name: kaku-tab
+
+before:
+ hooks:
+ - go mod tidy
+
+builds:
+ - id: kaku-tab
+ main: ./cmd/kaku-tab
+ binary: kaku-tab
+ env:
+ - CGO_ENABLED=0
+ goos: [linux, darwin]
+ goarch: [amd64, arm64]
+ # Matches the Makefile: main.version is what `kaku-tab version` prints.
+ ldflags:
+ - -s -w -X main.version={{ .Version }}
+ mod_timestamp: '{{ .CommitTimestamp }}'
+
+archives:
+ # The binary keeps its plain name inside the archive. kaku-tab.tmux looks for
+ # `kaku-tab` on PATH when there is no local build, so an archive that unpacks
+ # to kaku-tab_darwin_arm64 would install something the plugin cannot find.
+ - id: default
+ formats: [tar.gz]
+ name_template: >-
+ {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
+ files:
+ - README.md
+ - LICENSE
+ - kaku-tab.tmux
+ - docs/*
+
+checksum:
+ name_template: checksums.txt
+
+changelog:
+ use: github
+ sort: asc
+ filters:
+ exclude:
+ - '^docs:'
+ - '^test:'
+ - '^chore:'
+ - Merge pull request
+
+# A cask, not a formula: goreleaser deprecated `brews`, and a cask also strips
+# the macOS quarantine attribute from the downloaded binary. `brew install
+# --cask` is macOS-only, which is where Kaku runs; Linux users have `go install`
+# and the tarballs above.
+homebrew_casks:
+ - name: kaku-tab
+ binaries: [kaku-tab]
+ repository:
+ owner: dsaad68
+ name: homebrew-tap
+ token: '{{ .Env.HOMEBREW_TAP_TOKEN }}'
+ # Publishing needs the tap repo and a token with write access to it. Until
+ # both exist the release still succeeds and simply skips the cask, rather
+ # than failing after the archives are already uploaded.
+ skip_upload: '{{ if index .Env "HOMEBREW_TAP_TOKEN" }}false{{ else }}true{{ end }}'
+ homepage: https://github.com/dsaad68/kaku-tab
+ description: A tmux plugin that maps one tmux window to one terminal tab
+ caveats: |
+ This installs the binary only. tmux still needs the plugin entry point,
+ from TPM:
+
+ set -g @plugin 'dsaad68/kaku-tab'
+
+ or from a checkout:
+
+ run-shell '~/.tmux/plugins/kaku-tab/kaku-tab.tmux'
+
+ Either way it finds kaku-tab on PATH, so no build step runs.
+
+release:
+ draft: false
+ prerelease: auto
+ footer: |
+ ---
+ Installing: `brew install dsaad68/tap/kaku-tab`, or unpack the archive for
+ your platform and put `kaku-tab` on your PATH.
diff --git a/.testcoverage.yml b/.testcoverage.yml
new file mode 100644
index 0000000..f9f07d5
--- /dev/null
+++ b/.testcoverage.yml
@@ -0,0 +1,34 @@
+# Thresholds for github.com/vladopajic/go-test-coverage.
+#
+# These are a ratchet set just under where the tree stands today, not an
+# aspiration. The point is that a change cannot quietly delete coverage from
+# internal/resolve — the package the invariants in CLAUDE.md live in — on its
+# way to fixing something else.
+
+profile: cover.out
+
+exclude:
+ paths:
+ # Thin wrappers around an external process: the tmux CLI, the Kaku mux CLI,
+ # and argument parsing in main. A unit test of these can only assert the
+ # arguments back at itself, so they are excluded rather than pinned at 0
+ # and dragging the total into meaninglessness. They are covered instead by
+ # running the real thing — see "Verifying changes" in CLAUDE.md.
+ - ^cmd/kaku-tab/
+ - ^internal/tmux/
+ - ^internal/kaku/
+
+threshold:
+ file: 0
+ package: 0
+ total: 40
+
+override:
+ # The join. Everything else is presentation or plumbing around the table it
+ # produces, so this is the number that matters most.
+ - path: ^internal/resolve$
+ threshold: 85
+ - path: ^internal/ui$
+ threshold: 40
+ - path: ^internal/mru$
+ threshold: 80
diff --git a/CLAUDE.md b/CLAUDE.md
index 47ca5f6..ab7091f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,11 +8,16 @@ Go binary + a thin `kaku-tab.tmux` plugin entry point.
```sh
make build # bin/kaku-tab
make test # go vet + go test
-make lint # gofmt + vet
+make lint # golangci-lint, same .golangci.yml as CI
+make cover # tests + coverage thresholds from .testcoverage.yml
```
Tests use recorded fixtures — no tmux server or terminal needed.
+CI runs test / lint / coverage / goreleaser-check. The coverage thresholds are
+a ratchet set just under the current numbers: if a change drops
+`internal/resolve`, that is the signal, not a nuisance.
+
## Where to start
`internal/resolve` is the core: it joins the terminal's pane list to tmux's
diff --git a/Makefile b/Makefile
index 8d03d78..d713af6 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,13 @@ PKG := ./cmd/kaku-tab
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
-.PHONY: all build install test lint fmt clean
+# Pinned to match .github/workflows/ci.yml. A local lint that disagrees with CI
+# is worse than no local lint.
+GOLANGCI := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
+COVCHECK := github.com/vladopajic/go-test-coverage/v2@v2.19.0
+GORELEASER := github.com/goreleaser/goreleaser/v2@latest
+
+.PHONY: all build install test lint fmt cover release-snapshot clean
all: build
@@ -21,9 +27,23 @@ test:
fmt:
gofmt -w cmd internal
-lint: fmt
- go vet ./...
- @test -z "$$(gofmt -l cmd internal)" || (echo "unformatted files"; exit 1)
+## lint: the full linter set from .golangci.yml, same as CI. Uses an installed
+## golangci-lint when there is one, since `go run` rebuilds it from scratch.
+lint:
+ @if command -v golangci-lint >/dev/null 2>&1; then \
+ golangci-lint run; \
+ else \
+ go run $(GOLANGCI) run; \
+ fi
+
+## cover: run tests and check the thresholds in .testcoverage.yml
+cover:
+ go test -covermode=atomic -coverprofile=cover.out ./...
+ @go run $(COVCHECK) --config=.testcoverage.yml
+
+## release-snapshot: build the release artifacts locally, publishing nothing
+release-snapshot:
+ go run $(GORELEASER) release --snapshot --clean --skip=publish
clean:
- rm -rf bin
+ rm -rf bin dist cover.out
diff --git a/README.md b/README.md
index 44dbb7f..27357cd 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Works with [Kaku](https://github.com/tw93/Kaku) and [WezTerm](https://wezterm.or
╭── tmux ⇄ kaku ──────────────────────────────────────────────────────────────────╮
│ kaku-tab ❯ 17/17 │
├──────────────────────────────────────────────────────────────────────────────────┤
-│ ▸ ▾ api 3 windows ⟦kaku 15⟧ │
+│➤ ▾ api 3 windows ⟦kaku 15⟧ │
│ ├ ◍ 1 nvim 2p ~/src/api ⟦hidden 15⟧ │
│ ├ ◍ 2 zsh 2p ~/src/api/cmd ⟦hidden 15⟧ │
│ └ ● 3 just 2p ! ~/src/api ⟦kaku 15⟧ │
@@ -29,7 +29,7 @@ Works with [Kaku](https://github.com/tw93/Kaku) and [WezTerm](https://wezterm.or
│ └ ○ 2 htop 1p ~ ⟦ new tab ⟧ │
│ │
│ enter switch · ^/ show preview · ^t new tab · tab fold (S-tab all) · ^p panes │
-│ ^r rename · ^x kill · ^d detach · ^u clear │
+│ ^e hide detached · ^r rename · ^x kill · ^d detach · ^u clear │
╰──────────────────────────────────────────────────────────────────────────────────╯
```
@@ -72,6 +72,16 @@ set -g @plugin 'dsaad68/kaku-tab'
Then prefix+I. The plugin builds its binary on first load
if Go is available.
+### Homebrew
+
+```sh
+brew install --cask dsaad68/tap/kaku-tab
+```
+
+That installs the binary only; tmux still needs the plugin entry point, so pair
+it with the TPM line above or a checkout. Nothing gets built — the plugin finds
+`kaku-tab` on `PATH`.
+
### Manual
```sh
@@ -100,11 +110,14 @@ and press Alt+L.
| Key | Action |
|---|---|
+| ↑ ↓ | move (Ctrl+K / Ctrl+J too) |
+| PgUp PgDn Home End | move by a screenful, or to either end |
| Enter | switch to that window, reusing the session's existing tab |
| Ctrl+T | force a **new** tab, so two windows of one session show at once |
| Tab | fold/unfold a session (works from a child row too) |
| Shift+Tab | fold or unfold every session |
| Ctrl+P | toggle window ⇄ pane rows |
+| Ctrl+E | hide/show detached sessions — leaves only what's on screen |
| Ctrl+/ | show/hide the preview — the popup resizes with it |
| Ctrl+R | rename: the **window** on a child row, the **session** on a header |
| Ctrl+X | kill window |
@@ -115,6 +128,17 @@ and press Alt+L.
Typing filters. A session header matches on behalf of its windows, so `api`
shows the session *and* everything under it.
+When there are more rows than fit, a scrollbar appears down the right edge —
+otherwise a list that continues below the frame looks exactly like one that
+ends there. Shift+Tab folds every session, and
+Ctrl+E drops the detached ones, which are usually the
+faster ways to get a long list back onto one screen.
+
+Ctrl+E is a per-invocation toggle: it resets each time you
+open the picker, because a filter that quietly persisted would one day hide half
+your sessions with nothing on screen to say why. Set
+`@kaku-tab-detached 'off'` if you want it on by default.
+
### Enter vs Ctrl-T
For a window that is hidden — its session has a tab, but that tab is showing a
@@ -138,9 +162,16 @@ Every option, with defaults, is in
set -g @kaku-tab-key 'M-l' # picker binding
set -g @kaku-tab-search-key 'M-p' # optional: scrollback search
set -g @kaku-tab-preview 'off' # ^/ toggles it
+set -g @kaku-tab-sort 'tabs' # or 'mru' / 'name'
+set -g @kaku-tab-detached 'on' # 'off' starts with detached hidden; ^e toggles
set -g @kaku-tab-ignore 'popup' # sessions to hide, comma-separated
```
+With `@kaku-tab-sort 'mru'` the list is ordered by what you most recently
+switched to, and the window you are in now is pushed one place down — so
+Alt+L Enter toggles back to where you just
+were, alt-tab style.
+
## Scrollback search
Set `@kaku-tab-search-key` to get a live grep over every pane's scrollback in
@@ -172,9 +203,11 @@ brings the sessions back, this brings the tabs back.
## Development
```sh
-make build # bin/kaku-tab
-make test # go vet + go test
-make lint # gofmt + vet
+make build # bin/kaku-tab
+make test # go vet + go test
+make lint # golangci-lint, same config as CI
+make cover # tests + the thresholds in .testcoverage.yml
+make release-snapshot # build the release artifacts, publish nothing
```
Tests run against recorded fixtures — no tmux server or terminal required.
diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go
index 8e4c741..dc321a6 100644
--- a/cmd/kaku-tab/main.go
+++ b/cmd/kaku-tab/main.go
@@ -26,6 +26,7 @@ import (
"github.com/dsaad68/kaku-tab/internal/action"
"github.com/dsaad68/kaku-tab/internal/kaku"
"github.com/dsaad68/kaku-tab/internal/model"
+ "github.com/dsaad68/kaku-tab/internal/mru"
"github.com/dsaad68/kaku-tab/internal/resolve"
"github.com/dsaad68/kaku-tab/internal/tmux"
"github.com/dsaad68/kaku-tab/internal/ui"
@@ -51,6 +52,26 @@ func opts(selfSession string, withPanes bool) resolve.Options {
}
}
+// sortOption reads @kaku-tab-sort. An unrecognised value falls back to the
+// default rather than failing the picker over a typo in tmux.conf.
+func sortOption() string {
+ switch s := tmux.Option("@kaku-tab-sort", ui.SortTabs); s {
+ case ui.SortMRU, ui.SortName:
+ return s
+ default:
+ return ui.SortTabs
+ }
+}
+
+// mruList is only read for the sort mode that uses it — every tmux.Option is a
+// subprocess, and the picker runs one on every keypress-triggered popup.
+func mruList(sortMode string) []string {
+ if sortMode != ui.SortMRU {
+ return nil
+ }
+ return mru.List(mru.Tmux{})
+}
+
// ignored lists sessions to hide, e.g. a throwaway popup session bound to a
// key. Comma-separated in @kaku-tab-ignore; empty by default.
func ignored() []string {
@@ -139,8 +160,8 @@ func popup(selfTTY, selfSession string) error {
return err
}
path := stateFile.Name()
- stateFile.Close()
- defer os.Remove(path)
+ _ = stateFile.Close()
+ defer func() { _ = os.Remove(path) }()
self, _ := os.Executable()
preview := tmux.Option("@kaku-tab-preview", "off") == "on"
@@ -249,9 +270,18 @@ func pick(selfTTY, selfSession string) error {
preview = restore.Preview
}
+ // Deliberately not written back the way @kaku-tab-preview is. A filter that
+ // silently persisted would have you reopen the picker one day, find half
+ // your sessions gone, and have no idea why.
+ hideDetached := tmux.Option("@kaku-tab-detached", "on") == "off"
+ if resumed {
+ hideDetached = restore.HideDetached
+ }
+
self, _ := os.Executable()
ctx := action.Ctx{SelfTTY: selfTTY, Suffix: suffix, AttachSh: self}
+ sortMode := sortOption()
m := ui.New(ws, ui.Options{
Suffix: suffix,
SelfTab: selfTab,
@@ -262,6 +292,10 @@ func pick(selfTTY, selfSession string) error {
Reload: reload,
Ctx: ctx,
Restore: restore.State,
+ Sort: sortMode,
+ MRU: mruList(sortMode),
+
+ HideDetached: hideDetached,
})
// The picker owns the popup's terminal; Kaku's own alt-screen is untouched.
@@ -275,6 +309,10 @@ func pick(selfTTY, selfSession string) error {
if res.Relaunch || !res.Chosen {
return nil
}
+ // Recorded unconditionally, not just under @kaku-tab-sort 'mru': the list
+ // has to already be there for the option to do anything the first time it
+ // is switched on.
+ _ = mru.Record(mru.Tmux{}, res.Window.ID)
return action.Go(res.Window, res.PaneID, res.Mode, ctx)
}
@@ -305,6 +343,7 @@ func search(selfTTY, selfSession, query string) error {
if !res.Chosen {
return nil
}
+ _ = mru.Record(mru.Tmux{}, res.Window.ID)
return action.Go(res.Window, res.PaneID, res.Mode, ctx)
}
diff --git a/docs/configuration.md b/docs/configuration.md
index d8bd7bd..56792f9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -44,12 +44,43 @@ config.send_composed_key_when_right_alt_is_pressed = false
| Option | Default | Meaning |
|---|---|---|
| `@kaku-tab-open-mode` | `reuse` | what Enter does for a hidden window: `reuse` retargets the session's existing tab, `go` opens a new one |
+| `@kaku-tab-sort` | `tabs` | list order: `tabs`, `mru`, or `name` — see below |
+| `@kaku-tab-detached` | `on` | `off` starts with detached sessions hidden; Ctrl+E toggles |
| `@kaku-tab-scope` | `all` | `all`, `session` (current session only), or `group` (current session group) |
| `@kaku-tab-ignore` | *(empty)* | comma-separated session names to hide, e.g. a throwaway popup session |
| `@kaku-tab-satellite-suffix` | `~kaku` | naming for grouped satellite sessions |
| `@kaku-tab-mux-cli` | *(auto)* | force `kaku` or `wezterm` instead of auto-detecting |
| `@kaku-tab-search-depth` | `2000` | scrollback lines per pane indexed by search |
+### Sort order
+
+| Value | Order |
+|---|---|
+| `tabs` | sessions that have a terminal tab first, then the rest; alphabetical within each. The default. |
+| `mru` | whatever you most recently switched to, first — sessions and windows both. |
+| `name` | plain alphabetical, ignoring whether a session has a tab. |
+
+`mru` is the one to reach for if you bounce between the same two or three
+windows: the window you were in *before* this one sits at the top of the list,
+so Alt+L Enter is a straight toggle.
+
+The window you are currently in is deliberately pushed one place down. It heads
+the history — switching here is what recorded it — and leaving it at the top
+would put the cursor on a row whose Enter does nothing.
+
+Only windows you have switched to *through kaku-tab* are ranked; everything
+else falls in behind them in the `tabs` order. So a freshly started tmux server
+looks exactly like the default until the history fills in.
+
+The history is kept in a tmux option rather than a file, because tmux window
+ids (`@42`) mean nothing outside the server that issued them and a server
+option lives exactly that long:
+
+```sh
+tmux show-option -gqv @kaku-tab-mru # most recent first
+tmux set-option -gu @kaku-tab-mru # forget it
+```
+
## Tab titles
Off by default, because it takes over the tab title — which you may already be
diff --git a/internal/action/titles.go b/internal/action/titles.go
index 7bbd87d..9472eab 100644
--- a/internal/action/titles.go
+++ b/internal/action/titles.go
@@ -11,7 +11,8 @@ import (
"github.com/dsaad68/kaku-tab/internal/tmux"
)
-// TitleFormat placeholders:
+// DefaultTitleFormat is the tab title template used when @kaku-tab-title-format
+// is unset. Placeholders:
//
// %s session name (a satellite keeps its own name)
// %g base session name, with any satellite suffix stripped
diff --git a/internal/mru/mru.go b/internal/mru/mru.go
new file mode 100644
index 0000000..289ee31
--- /dev/null
+++ b/internal/mru/mru.go
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: MIT
+
+// Package mru remembers which tmux windows you most recently switched to.
+//
+// tmux exposes no per-window "last used" timestamp. #{window_activity} is
+// output-driven, so a window running top or a build is permanently "most
+// recent" whether or not you have looked at it, and #{session_last_attached}
+// only moves when a client attaches — which is exactly what kaku-tab avoids
+// doing when it retargets an existing tab. Neither answers "where was I?", so
+// kaku-tab records its own picks.
+//
+// The list lives in a tmux server option rather than a state file. Window ids
+// (@42) are only meaningful for the life of one tmux server, which is precisely
+// how long a server option survives: nothing to migrate, nothing to garbage
+// collect, and `tmux show-option -gqv @kaku-tab-mru` shows the whole thing when
+// the order looks wrong.
+package mru
+
+import (
+ "strings"
+
+ "github.com/dsaad68/kaku-tab/internal/tmux"
+)
+
+// Option is the tmux server option holding the list, most recent first.
+const Option = "@kaku-tab-mru"
+
+// Cap bounds the stored list. Past a few dozen entries the tail is noise, and
+// this keeps the option from growing without limit on a long-lived server.
+const Cap = 64
+
+// Store is where the list is kept. Production uses Tmux; tests use their own.
+type Store interface {
+ Get() string
+ Set(string) error
+}
+
+// Tmux stores the list in a tmux server option.
+type Tmux struct{}
+
+func (Tmux) Get() string { return tmux.Option(Option, "") }
+
+func (Tmux) Set(v string) error { return tmux.SetOption(Option, v) }
+
+// List returns recorded window ids, most recent first.
+func List(s Store) []string {
+ return split(s.Get())
+}
+
+// Record pushes a window id to the front, removing any earlier occurrence so an
+// id appears exactly once.
+func Record(s Store, id string) error {
+ if id == "" {
+ return nil
+ }
+ out := []string{id}
+ for _, prev := range split(s.Get()) {
+ if prev != id && len(out) < Cap {
+ out = append(out, prev)
+ }
+ }
+ return s.Set(strings.Join(out, ","))
+}
+
+// Ranks maps window id to position, 0 being the most recent. Ids absent from
+// the list are absent from the map; callers order those by their usual rule.
+//
+// current — the window the picker was invoked from — is demoted one place when
+// it heads the list. It always does head it, because switching here is what
+// recorded it. Leaving it at 0 would put "where you already are" under the
+// cursor and make Enter a no-op, which is the one thing an MRU order exists to
+// avoid; alt-tab has the same rule for the same reason.
+func Ranks(list []string, current string) map[string]int {
+ order := append([]string(nil), list...)
+ if current != "" && len(order) > 1 && order[0] == current {
+ order[0], order[1] = order[1], order[0]
+ }
+ m := make(map[string]int, len(order))
+ for i, id := range order {
+ if _, dup := m[id]; !dup {
+ m[id] = i
+ }
+ }
+ return m
+}
+
+func split(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ var out []string
+ for _, s := range strings.Split(raw, ",") {
+ if s = strings.TrimSpace(s); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
diff --git a/internal/mru/mru_test.go b/internal/mru/mru_test.go
new file mode 100644
index 0000000..73343e0
--- /dev/null
+++ b/internal/mru/mru_test.go
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: MIT
+
+package mru
+
+import (
+ "strings"
+ "testing"
+)
+
+type memStore struct{ v string }
+
+func (s *memStore) Get() string { return s.v }
+
+func (s *memStore) Set(v string) error { s.v = v; return nil }
+
+func TestRecordPushesToFrontAndDeduplicates(t *testing.T) {
+ s := &memStore{}
+ for _, id := range []string{"@1", "@2", "@3", "@2"} {
+ if err := Record(s, id); err != nil {
+ t.Fatal(err)
+ }
+ }
+ want := "@2,@3,@1"
+ if s.v != want {
+ t.Errorf("got %q want %q", s.v, want)
+ }
+}
+
+// The option lives on a long-running tmux server, so an unbounded list would
+// grow for as long as that server does.
+func TestRecordCapsTheList(t *testing.T) {
+ s := &memStore{}
+ for i := 0; i < Cap*2; i++ {
+ if err := Record(s, "@"+itoa(i)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := len(List(s)); got != Cap {
+ t.Errorf("stored %d entries, want %d", got, Cap)
+ }
+ if got := List(s)[0]; got != "@"+itoa(Cap*2-1) {
+ t.Errorf("most recent is %q, want the last recorded", got)
+ }
+}
+
+func TestListIgnoresBlanksAndEmptyOption(t *testing.T) {
+ if got := List(&memStore{}); got != nil {
+ t.Errorf("empty option gave %v, want nil", got)
+ }
+ got := List(&memStore{v: " @1 , ,@2, "})
+ if strings.Join(got, ",") != "@1,@2" {
+ t.Errorf("got %v", got)
+ }
+}
+
+// The head of the list is always the window you just switched to, i.e. where
+// you are now. Ranking it first would put the cursor on a row whose Enter does
+// nothing — the one outcome an MRU order exists to prevent.
+func TestRanksDemotesTheCurrentWindow(t *testing.T) {
+ r := Ranks([]string{"@1", "@2", "@3"}, "@1")
+ if r["@2"] != 0 {
+ t.Errorf("@2 rank %d, want 0", r["@2"])
+ }
+ if r["@1"] != 1 {
+ t.Errorf("@1 rank %d, want 1", r["@1"])
+ }
+ if r["@3"] != 2 {
+ t.Errorf("@3 rank %d, want 2", r["@3"])
+ }
+}
+
+func TestRanksLeavesListAloneWhenCurrentIsNotHead(t *testing.T) {
+ list := []string{"@1", "@2"}
+ r := Ranks(list, "@2")
+ if r["@1"] != 0 || r["@2"] != 1 {
+ t.Errorf("got %v, want @1=0 @2=1", r)
+ }
+ // Ranks must not reorder the caller's slice: the picker rebuilds rows on
+ // every reload from the same list.
+ if list[0] != "@1" || list[1] != "@2" {
+ t.Errorf("input slice mutated: %v", list)
+ }
+}
+
+func TestRanksHandlesDegenerateInput(t *testing.T) {
+ if got := Ranks(nil, "@1"); len(got) != 0 {
+ t.Errorf("nil list gave %v", got)
+ }
+ if got := Ranks([]string{"@1"}, "@1"); got["@1"] != 0 {
+ t.Errorf("single entry gave %v, want @1=0", got)
+ }
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var b []byte
+ for n > 0 {
+ b = append([]byte{byte('0' + n%10)}, b...)
+ n /= 10
+ }
+ return string(b)
+}
diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go
index c71ec02..0a6eaad 100644
--- a/internal/tmux/tmux.go
+++ b/internal/tmux/tmux.go
@@ -213,3 +213,10 @@ func Option(name, def string) string {
}
return strings.TrimSpace(out)
}
+
+// SetOption writes a global tmux option. Used for the small amount of state
+// kaku-tab keeps on the server rather than on disk.
+func SetOption(name, value string) error {
+ _, err := Run("set-option", "-g", name, value)
+ return err
+}
diff --git a/internal/ui/detached_test.go b/internal/ui/detached_test.go
new file mode 100644
index 0000000..4d7a18b
--- /dev/null
+++ b/internal/ui/detached_test.go
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: MIT
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/dsaad68/kaku-tab/internal/model"
+)
+
+func toggleDetached(m *Model) {
+ m.Update(tea.KeyMsg{Type: tea.KeyCtrlE})
+}
+
+// sample() has "termdown" detached and "api" attached.
+func TestHideDetachedDropsWholeSessions(t *testing.T) {
+ m := New(sample(), Options{Tree: true, SelfTab: "8", HideDetached: true})
+
+ if got := sessions(m); strings.Join(got, ",") != "api" {
+ t.Errorf("got %v, want only the attached session", got)
+ }
+ for _, r := range m.rows {
+ if r.status == model.Detached {
+ t.Errorf("detached row survived: %s", r.search)
+ }
+ }
+}
+
+// The header would be left behind pointing at nothing.
+func TestHideDetachedRemovesTheHeaderToo(t *testing.T) {
+ m := New(sample(), Options{Tree: true, SelfTab: "8", HideDetached: true})
+ for _, r := range m.rows {
+ if r.kind == kindHeader && r.group == "termdown" {
+ t.Error("header for a fully hidden session is still listed")
+ }
+ }
+}
+
+func TestToggleDetachedIsReversible(t *testing.T) {
+ m := New(sample(), Options{Tree: true, SelfTab: "8"})
+ before := len(m.rows)
+
+ toggleDetached(m)
+ if len(m.rows) >= before {
+ t.Errorf("hiding changed nothing: %d -> %d rows", before, len(m.rows))
+ }
+ if !m.opt.HideDetached {
+ t.Error("flag not set")
+ }
+
+ toggleDetached(m)
+ if len(m.rows) != before {
+ t.Errorf("unhiding did not restore the list: %d -> %d rows", before, len(m.rows))
+ }
+}
+
+// Unhiding inserts whole sessions, which can be above the cursor. Without
+// holding the row, the selection lands on something unrelated and Enter goes
+// somewhere the user did not choose.
+func TestToggleDetachedKeepsTheCursorOnItsRow(t *testing.T) {
+ m := New(sample(), Options{Tree: true, SelfTab: "8", HideDetached: true})
+ m.width, m.height = 150, 30
+
+ // Land on a window row of "api", the one session that survives hiding.
+ for i, vi := range m.view {
+ if m.rows[vi].kind == kindWindow {
+ m.cursor = i
+ break
+ }
+ }
+ want, _ := m.current()
+
+ toggleDetached(m)
+
+ got, ok := m.current()
+ if !ok {
+ t.Fatal("cursor left the view")
+ }
+ if got.win.ID != want.win.ID {
+ t.Errorf("cursor moved from window %s to %s", want.win.ID, got.win.ID)
+ }
+}
+
+// "no matches" reads as "you have no sessions" when the real cause is a filter
+// the user may have forgotten they turned on.
+func TestEmptyListExplainsTheFilter(t *testing.T) {
+ ws := []model.Window{
+ {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1"}, Status: model.Detached},
+ }
+ m := New(ws, Options{Tree: true, HideDetached: true})
+ m.width, m.height = 150, 30
+
+ if got := m.View(); !strings.Contains(got, "^e shows detached") {
+ t.Error("empty list does not say the filter is why")
+ }
+}
+
+func TestHelpBarNamesTheDirectionOfTheToggle(t *testing.T) {
+ shown := New(sample(), Options{Tree: true})
+ if !hasPair(shown.helpPairs(), "^e", "hide detached") {
+ t.Errorf("with detached shown, help says %v", shown.helpPairs())
+ }
+
+ hidden := New(sample(), Options{Tree: true, HideDetached: true})
+ if !hasPair(hidden.helpPairs(), "^e", "show detached") {
+ t.Errorf("with detached hidden, help says %v", hidden.helpPairs())
+ }
+}
+
+func hasPair(pairs [][2]string, key, label string) bool {
+ for _, p := range pairs {
+ if p[0] == key && p[1] == label {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/ui/panerow_test.go b/internal/ui/panerow_test.go
new file mode 100644
index 0000000..aaedf41
--- /dev/null
+++ b/internal/ui/panerow_test.go
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: MIT
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/mattn/go-runewidth"
+
+ "github.com/dsaad68/kaku-tab/internal/model"
+)
+
+func paneSample() []model.Window {
+ return []model.Window{
+ {
+ RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1", Name: "nvim", Panes: 2, Path: "/home/u/api"},
+ Status: model.Visible, TabID: "8", GUIWin: "0", ClientSession: "api",
+ Panes_: []model.Pane{
+ {ID: "%1", Index: "1", Cmd: "nvim", Path: "/home/u/api", Active: true},
+ {ID: "%2", Index: "2", Cmd: "zsh", Path: "/home/u/api"},
+ },
+ },
+ }
+}
+
+func paneModel(t *testing.T) *Model {
+ t.Helper()
+ m := New(paneSample(), Options{Tree: true, PaneMode: true, SelfTab: "8"})
+ m.width, m.height = 150, 30
+ return m
+}
+
+// The marker used to hang directly off the glyph — "●*" read as one smudged
+// symbol rather than a status and a flag.
+func TestActivePaneMarkerIsNotFlushAgainstTheGlyph(t *testing.T) {
+ m := paneModel(t)
+ for _, r := range m.rows {
+ if r.kind != kindPane || !r.pane.Active {
+ continue
+ }
+ plain := ansi.Strip(m.renderRow(r, false))
+ if strings.Contains(plain, "●*") || strings.Contains(plain, "◍*") || strings.Contains(plain, "○*") {
+ t.Errorf("marker is flush against the glyph: %q", strings.TrimRight(plain, " "))
+ }
+ return
+ }
+ t.Fatal("no active pane row")
+}
+
+// Reserving the marker column on every pane row is what keeps the columns
+// straight: when only the active row carried it, that row's label, command and
+// path all sat one cell right of its neighbours'.
+func TestPaneRowColumnsAlignRegardlessOfTheMarker(t *testing.T) {
+ m := paneModel(t)
+
+ var cols []int
+ for _, r := range m.rows {
+ if r.kind != kindPane {
+ continue
+ }
+ plain := ansi.Strip(m.renderRow(r, false))
+ i := strings.Index(plain, "1.")
+ if i < 0 {
+ t.Fatalf("no pane label in %q", plain)
+ }
+ cols = append(cols, runewidth.StringWidth(plain[:i]))
+ }
+ if len(cols) < 2 {
+ t.Fatal("need at least two pane rows")
+ }
+ for _, c := range cols[1:] {
+ if c != cols[0] {
+ t.Errorf("label column drifts across pane rows: %v", cols)
+ break
+ }
+ }
+}
+
+// The cursor bar and a collapsed session's fold arrow used to be the same
+// glyph, sitting two columns apart and meaning different things.
+func TestCursorGlyphIsNotTheFoldArrow(t *testing.T) {
+ m := newTestModel(t)
+ for _, r := range m.rows {
+ sel := ansi.Strip(m.renderRow(r, true))
+ if strings.HasPrefix(strings.TrimLeft(sel, " "), "▸") {
+ t.Errorf("cursor still renders as the fold arrow: %q", sel)
+ }
+ }
+}
diff --git a/internal/ui/scrollbar_test.go b/internal/ui/scrollbar_test.go
new file mode 100644
index 0000000..ae6455b
--- /dev/null
+++ b/internal/ui/scrollbar_test.go
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: MIT
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+func blanks(n, w int) []string {
+ out := make([]string, n)
+ for i := range out {
+ out[i] = strings.Repeat(" ", w)
+ }
+ return out
+}
+
+// The gutter is what tells you the list did not end at the frame.
+func TestScrollbarAppearsOnlyWhenTheListOverflows(t *testing.T) {
+ fits := withScrollbar(blanks(5, 10), 10, 5, 0)
+ for i, l := range fits {
+ if got := ansi.Strip(l); strings.ContainsAny(got, "│┃") {
+ t.Errorf("line %d drew a bar for a list that fits: %q", i, got)
+ }
+ }
+
+ overflows := withScrollbar(blanks(5, 10), 10, 40, 0)
+ if !strings.ContainsAny(ansi.Strip(strings.Join(overflows, "")), "│┃") {
+ t.Error("no bar drawn for a list four times the viewport")
+ }
+}
+
+// A gutter that came and went with the query would re-budget every column.
+func TestScrollbarWidthIsConstant(t *testing.T) {
+ for _, total := range []int{1, 5, 6, 500} {
+ lines := withScrollbar(blanks(5, 10), 10, total, 0)
+ for i, l := range lines {
+ if got := ansi.StringWidth(l); got != 10+scrollbarCells {
+ t.Errorf("total=%d line %d width %d, want %d", total, i, got, 10+scrollbarCells)
+ }
+ }
+ }
+}
+
+// The thumb has to reach the bottom at the bottom, or a list you have scrolled
+// all the way through still looks like it continues.
+func TestScrollbarThumbTracksTheOffset(t *testing.T) {
+ const h, total = 5, 40
+
+ top := thumbRows(withScrollbar(blanks(h, 4), 4, total, 0))
+ if len(top) == 0 || top[0] != 0 {
+ t.Errorf("at offset 0 the thumb sits at rows %v, want it to start at 0", top)
+ }
+
+ bottom := thumbRows(withScrollbar(blanks(h, 4), 4, total, total-h))
+ if len(bottom) == 0 || bottom[len(bottom)-1] != h-1 {
+ t.Errorf("at the last offset the thumb sits at rows %v, want it to end at %d", bottom, h-1)
+ }
+}
+
+// On a long list an exactly proportional thumb rounds to zero cells and the bar
+// disappears at the size where it matters most.
+func TestScrollbarThumbSurvivesAVeryLongList(t *testing.T) {
+ if got := thumbRows(withScrollbar(blanks(5, 4), 4, 100000, 0)); len(got) == 0 {
+ t.Error("thumb vanished on a 100k-row list")
+ }
+}
+
+// An offset past the end must not index outside the gutter.
+func TestScrollbarClampsAnOutOfRangeOffset(t *testing.T) {
+ lines := withScrollbar(blanks(5, 4), 4, 40, 999)
+ if got := thumbRows(lines); len(got) == 0 || got[len(got)-1] != 4 {
+ t.Errorf("thumb rows %v, want it pinned to the bottom", got)
+ }
+}
+
+func thumbRows(lines []string) []int {
+ var out []int
+ for i, l := range lines {
+ if strings.Contains(ansi.Strip(l), "┃") {
+ out = append(out, i)
+ }
+ }
+ return out
+}
diff --git a/internal/ui/search.go b/internal/ui/search.go
index 56c78dc..47110f1 100644
--- a/internal/ui/search.go
+++ b/internal/ui/search.go
@@ -262,6 +262,7 @@ func (m *SearchModel) View() string {
prompt := promptLeft + strings.Repeat(" ", maxInt(1, gap)) + right + " "
h := m.listHeight()
+ rw := maxInt(20, w-scrollbarCells)
lines := make([]string, 0, h)
switch {
case m.indexing:
@@ -277,11 +278,11 @@ func (m *SearchModel) View() string {
loc := fmt.Sprintf("%s:%s.%s", hit.win.Session, hit.win.Index, hit.pane.Index)
prefix := " "
if i == m.cursor {
- prefix = " " + cPrompt.Render("▸ ")
+ prefix = cCursor.Render("➤") + " "
}
line := prefix + cGroup.Render(pad(loc, 20)) + " " + m.badge(hit.win) + " " +
cDim.Render(fmt.Sprintf("%5d ", hit.line)) + highlight(hit.text, m.query)
- line = padToWidth(truncateANSI(line, w), w)
+ line = padToWidth(truncateANSI(line, rw), rw)
if i == m.cursor {
line = cSel.Render(line)
}
@@ -289,8 +290,11 @@ func (m *SearchModel) View() string {
}
}
for len(lines) < h {
- lines = append(lines, strings.Repeat(" ", w))
+ lines = append(lines, strings.Repeat(" ", rw))
}
+ // A scrollback grep routinely returns hundreds of hits, so this list
+ // overflows far more often than the window list does.
+ lines = withScrollbar(lines, rw, len(m.view), m.offset)
hl := helpBarLines([][2]string{
{"enter", "jump"}, {"^t", "new tab"}, {"^u", "clear"}, {"esc", "cancel"},
diff --git a/internal/ui/sort_test.go b/internal/ui/sort_test.go
new file mode 100644
index 0000000..6e96dd0
--- /dev/null
+++ b/internal/ui/sort_test.go
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: MIT
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/dsaad68/kaku-tab/internal/model"
+)
+
+// sessions lists header groups in the order they were built.
+func sessions(m *Model) []string {
+ var out []string
+ for _, r := range m.rows {
+ if r.kind == kindHeader {
+ out = append(out, r.group)
+ }
+ }
+ return out
+}
+
+// windowIDs lists the window rows of one group, in order.
+func windowIDs(m *Model, group string) []string {
+ var out []string
+ for _, r := range m.rows {
+ if r.kind == kindWindow && r.group == group {
+ out = append(out, r.win.ID)
+ }
+ }
+ return out
+}
+
+func TestSortTabsIsTheDefault(t *testing.T) {
+ m := New(sample(), Options{Tree: true, SelfTab: "8"})
+ if got := sessions(m); strings.Join(got, ",") != "api,termdown" {
+ t.Errorf("got %v, want attached session first", got)
+ }
+ if got := windowIDs(m, "api"); strings.Join(got, ",") != "@1,@2" {
+ t.Errorf("got %v, want resolve's index order", got)
+ }
+}
+
+// sample() has api:@2 visible in the invoking tab, so @2 heads any MRU list and
+// must be demoted: the session holding the next-most-recent window sorts first.
+func TestSortMRUOrdersSessionsAndWindowsByRecency(t *testing.T) {
+ m := New(sample(), Options{
+ Tree: true, SelfTab: "8",
+ Sort: SortMRU,
+ MRU: []string{"@2", "@3", "@1"},
+ })
+
+ if got := sessions(m); strings.Join(got, ",") != "termdown,api" {
+ t.Errorf("session order %v, want termdown first — @3 outranks the demoted @2", got)
+ }
+ if got := windowIDs(m, "api"); strings.Join(got, ",") != "@2,@1" {
+ t.Errorf("window order %v, want @2 before @1", got)
+ }
+}
+
+// Enter on the top header must not land on the window you are already in — the
+// whole point of demoting the current window one place.
+func TestSortMRUHeaderDoesNotTargetTheCurrentWindow(t *testing.T) {
+ m := New(sample(), Options{
+ Tree: true, SelfTab: "8",
+ Sort: SortMRU,
+ MRU: []string{"@2", "@1"}, // only session "api" has been visited
+ })
+ for _, r := range m.rows {
+ if r.kind == kindHeader && r.group == "api" {
+ if r.win.ID == "@2" {
+ t.Error("header targets @2, the window already showing in this tab")
+ }
+ return
+ }
+ }
+ t.Fatal("no api header")
+}
+
+// Nothing recorded yet — a fresh tmux server — must look exactly like the
+// default, or turning the option on would scramble the list until you had used
+// the picker enough times to fill it.
+func TestSortMRUWithNoHistoryMatchesDefault(t *testing.T) {
+ def := New(sample(), Options{Tree: true, SelfTab: "8"})
+ byMRU := New(sample(), Options{Tree: true, SelfTab: "8", Sort: SortMRU})
+
+ if a, b := sessions(def), sessions(byMRU); strings.Join(a, ",") != strings.Join(b, ",") {
+ t.Errorf("sessions %v != %v", b, a)
+ }
+ if a, b := windowIDs(def, "api"), windowIDs(byMRU, "api"); strings.Join(a, ",") != strings.Join(b, ",") {
+ t.Errorf("windows %v != %v", b, a)
+ }
+}
+
+// Windows that have never been picked keep their index order behind the ones
+// that have, rather than being shuffled by map iteration.
+func TestSortMRUKeepsUnvisitedWindowsInIndexOrder(t *testing.T) {
+ ws := []model.Window{
+ {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1"}, Status: model.Detached},
+ {RawWindow: model.RawWindow{Session: "api", ID: "@2", Index: "2"}, Status: model.Detached},
+ {RawWindow: model.RawWindow{Session: "api", ID: "@3", Index: "3"}, Status: model.Detached},
+ }
+ m := New(ws, Options{Tree: true, Sort: SortMRU, MRU: []string{"@3"}})
+ if got := windowIDs(m, "api"); strings.Join(got, ",") != "@3,@1,@2" {
+ t.Errorf("got %v, want the visited window first then index order", got)
+ }
+}
+
+func TestSortNameIgnoresWhetherASessionHasATab(t *testing.T) {
+ ws := append(sample(),
+ model.Window{RawWindow: model.RawWindow{Session: "aaa", ID: "@9", Index: "1"}, Status: model.Detached})
+
+ byTabs := New(ws, Options{Tree: true, SelfTab: "8"})
+ if got := sessions(byTabs); got[0] != "api" {
+ t.Errorf("default put %q first, want the session with a tab", got[0])
+ }
+
+ byName := New(ws, Options{Tree: true, SelfTab: "8", Sort: SortName})
+ if got := sessions(byName); strings.Join(got, ",") != "aaa,api,termdown" {
+ t.Errorf("got %v, want plain alphabetical", got)
+ }
+}
diff --git a/internal/ui/text.go b/internal/ui/text.go
index bb6fd23..f7796c4 100644
--- a/internal/ui/text.go
+++ b/internal/ui/text.go
@@ -34,6 +34,52 @@ func padToWidth(s string, w int) string {
return s + strings.Repeat(" ", w-n)
}
+// scrollbarCells is the gutter reserved at the right edge of a list.
+//
+// Reserved always, even when every row fits. A gutter that appeared only on
+// overflow would re-budget every column the moment a query filtered a row away,
+// and column widths are a property of the table, not of the query.
+const scrollbarCells = 1
+
+// withScrollbar pads each line to width and appends the gutter: a track with a
+// thumb sized to the fraction of the list on screen.
+//
+// Without it a list that runs past the viewport looks exactly like a list that
+// ends there. The rows simply stop at the frame, with nothing to say the next
+// session is one keypress below.
+func withScrollbar(lines []string, width, total, offset int) []string {
+ h := len(lines)
+ if h == 0 {
+ return lines
+ }
+ if total <= h {
+ for i := range lines {
+ lines[i] = padToWidth(lines[i], width) + " "
+ }
+ return lines
+ }
+
+ // Never smaller than one cell: on a long enough list an exactly
+ // proportional thumb rounds to zero and vanishes where it is needed most.
+ size := maxInt(1, h*h/total)
+ start := offset * (h - size) / (total - h)
+ if start < 0 {
+ start = 0
+ }
+ if start > h-size {
+ start = h - size
+ }
+
+ for i := range lines {
+ cell := cBorder.Render("│")
+ if i >= start && i < start+size {
+ cell = cThumb.Render("┃")
+ }
+ lines[i] = padToWidth(lines[i], width) + cell
+ }
+ return lines
+}
+
var (
homeOnce sync.Once
home string
diff --git a/internal/ui/theme.go b/internal/ui/theme.go
index 0ff32ac..0e86861 100644
--- a/internal/ui/theme.go
+++ b/internal/ui/theme.go
@@ -37,6 +37,8 @@ var (
cHead = lipgloss.NewStyle().Foreground(colMuted)
cPrompt = lipgloss.NewStyle().Foreground(colAccent).Bold(true)
cBorder = lipgloss.NewStyle().Foreground(colBorder)
+ cThumb = lipgloss.NewStyle().Foreground(colAccent)
+ cCursor = lipgloss.NewStyle().Foreground(colAccent)
cTitle = lipgloss.NewStyle().Foreground(colAccent).Bold(true)
cKey = lipgloss.NewStyle().Foreground(colText).Bold(true)
cText = lipgloss.NewStyle().Foreground(colText)
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 63e570d..6267ec1 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -29,9 +29,21 @@ import (
"github.com/dsaad68/kaku-tab/internal/action"
"github.com/dsaad68/kaku-tab/internal/kaku"
"github.com/dsaad68/kaku-tab/internal/model"
+ "github.com/dsaad68/kaku-tab/internal/mru"
"github.com/dsaad68/kaku-tab/internal/tmux"
)
+// Sort modes for the session list, from @kaku-tab-sort.
+const (
+ // SortTabs lists sessions that have a terminal tab first, then the rest,
+ // alphabetically within each. The default.
+ SortTabs = "tabs"
+ // SortMRU lists whatever you most recently switched to first.
+ SortMRU = "mru"
+ // SortName is plain alphabetical, ignoring whether a session has a tab.
+ SortName = "name"
+)
+
type rowKind int
const (
@@ -69,12 +81,13 @@ type Result struct {
// State survives a relaunch so toggling the preview does not lose your place.
type State struct {
- Query string `json:"query"`
- Cursor int `json:"cursor"`
- Offset int `json:"offset"`
- Preview bool `json:"preview"`
- PaneMode bool `json:"pane_mode"`
- Collapse map[string]bool `json:"collapse"`
+ Query string `json:"query"`
+ Cursor int `json:"cursor"`
+ Offset int `json:"offset"`
+ Preview bool `json:"preview"`
+ PaneMode bool `json:"pane_mode"`
+ HideDetached bool `json:"hide_detached"`
+ Collapse map[string]bool `json:"collapse"`
}
type Options struct {
@@ -86,8 +99,13 @@ type Options struct {
OpenMode action.Mode
Reload func(panes bool) ([]model.Window, error)
Ctx action.Ctx
- Depth int // scrollback lines per pane for search
- Restore State // carried across a preview-toggle relaunch
+ Depth int // scrollback lines per pane for search
+ Restore State // carried across a preview-toggle relaunch
+ Sort string // SortTabs (default), SortMRU, or SortName
+ MRU []string // window ids, most recent first; only read for SortMRU
+
+ // HideDetached drops sessions with no terminal tab from the list.
+ HideDetached bool
}
type previewMsg struct {
@@ -151,7 +169,8 @@ func New(ws []model.Window, opt Options) *Model {
func (m *Model) State() State {
return State{
Query: m.query, Cursor: m.cursor, Offset: m.offset,
- Preview: m.opt.Preview, PaneMode: m.opt.PaneMode, Collapse: m.collapse,
+ Preview: m.opt.Preview, PaneMode: m.opt.PaneMode,
+ HideDetached: m.opt.HideDetached, Collapse: m.collapse,
}
}
@@ -160,9 +179,23 @@ func (m *Model) Result() Result { return m.result }
// build turns resolved windows into tree rows.
func (m *Model) build() {
m.rows = nil
+
+ // tmux marks every window of a client-less session Detached, so this drops
+ // whole sessions rather than punching holes in one — which is the point:
+ // what is left is exactly what you can switch between right now.
+ windows := m.windows
+ if m.opt.HideDetached {
+ windows = make([]model.Window, 0, len(m.windows))
+ for _, w := range m.windows {
+ if w.Status != model.Detached {
+ windows = append(windows, w)
+ }
+ }
+ }
+
groups := map[string][]model.Window{}
var order []string
- for _, w := range m.windows {
+ for _, w := range windows {
g := w.Session
if m.opt.PaneMode {
g = w.Session + ":" + w.Index
@@ -185,11 +218,41 @@ func (m *Model) build() {
}
}
}
+
+ // Under SortMRU the recorded order wins, and everything you have never
+ // switched to falls back to the rules above — so a fresh tmux server, with
+ // nothing recorded yet, looks exactly like SortTabs.
+ best := map[string]int{} // group -> best rank in it; absent = never picked
+ if m.opt.Sort == SortMRU {
+ ranks := mru.Ranks(m.opt.MRU, m.here())
+ for g, ws := range groups {
+ sortByRank(ws, ranks)
+ for _, w := range ws {
+ if r, ok := ranks[w.ID]; ok {
+ if cur, seen := best[g]; !seen || r < cur {
+ best[g] = r
+ }
+ }
+ }
+ }
+ }
+
sort.SliceStable(order, func(i, j int) bool {
- if attached[order[i]] != attached[order[j]] {
- return attached[order[i]]
+ a, b := order[i], order[j]
+ if m.opt.Sort == SortMRU {
+ ra, oka := best[a]
+ rb, okb := best[b]
+ if oka != okb {
+ return oka
+ }
+ if oka && ra != rb {
+ return ra < rb
+ }
+ }
+ if m.opt.Sort != SortName && attached[a] != attached[b] {
+ return attached[a]
}
- return order[i] < order[j]
+ return a < b
})
for _, g := range order {
@@ -214,7 +277,7 @@ func (m *Model) build() {
m.rows = append(m.rows, row{
kind: kindHeader, group: g, search: g, count: n,
status: hstat, tabID: htab,
- win: pickHeaderWindow(ws),
+ win: pickHeaderWindow(ws, m.opt.Sort == SortMRU),
})
}
for i, w := range ws {
@@ -240,9 +303,48 @@ func (m *Model) build() {
m.refilter()
}
+// here is the tmux window displayed in the tab the picker was invoked from, or
+// "" when the picker cannot tell (no Kaku CLI, or a client outside a tab).
+func (m *Model) here() string {
+ if m.opt.SelfTab == "" {
+ return ""
+ }
+ for _, w := range m.windows {
+ if w.Status == model.Visible && w.TabID == m.opt.SelfTab {
+ return w.ID
+ }
+ }
+ return ""
+}
+
+// sortByRank puts recently switched-to windows first. Windows with no recorded
+// rank keep resolve's index order behind them, which is why the comparison
+// returns false rather than falling through to an index compare.
+func sortByRank(ws []model.Window, ranks map[string]int) {
+ sort.SliceStable(ws, func(i, j int) bool {
+ ri, oki := ranks[ws[i].ID]
+ rj, okj := ranks[ws[j].ID]
+ if oki != okj {
+ return oki
+ }
+ if !oki {
+ return false
+ }
+ return ri < rj
+ })
+}
+
// pickHeaderWindow chooses what Enter on a header targets: the window that
// session is currently showing, else its first.
-func pickHeaderWindow(ws []model.Window) model.Window {
+//
+// Under an MRU order the group is already sorted by where you were, and the
+// currently-showing window is the one place Enter must not land — so the first
+// row wins instead. Without this the top header of an MRU list would target the
+// window you are already in.
+func pickHeaderWindow(ws []model.Window, byMRU bool) model.Window {
+ if byMRU {
+ return ws[0]
+ }
for _, w := range ws {
if w.Status == model.Visible {
return w
@@ -334,10 +436,14 @@ func (m *Model) helpPairs() [][2]string {
if !m.opt.Preview {
preview = "show preview"
}
+ detached := "hide detached"
+ if m.opt.HideDetached {
+ detached = "show detached"
+ }
pairs := [][2]string{
{"enter", "switch"}, {"^/", preview}, {"^t", "new tab"}, {"tab", "fold"},
- {"^p", "panes"}, {"^r", "rename"}, {"^x", "kill"}, {"^d", "detach"},
- {"^u", "clear"},
+ {"^p", "panes"}, {"^e", detached}, {"^r", "rename"}, {"^x", "kill"},
+ {"^d", "detach"}, {"^u", "clear"},
}
if m.opt.Tree {
pairs[3] = [2]string{"tab", "fold (S-tab all)"}
@@ -575,6 +681,19 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.opt.PaneMode = !m.opt.PaneMode
return m, m.reloadCmd()
+ case "ctrl+e":
+ // Hold the cursor on the same row across the toggle. Unhiding inserts
+ // whole sessions above it, so without this the selection lands on
+ // something unrelated and Enter does the wrong thing.
+ keepID, keepHeader := "", false
+ if r, ok := m.current(); ok {
+ keepID, keepHeader = r.win.ID, r.kind == kindHeader
+ }
+ m.opt.HideDetached = !m.opt.HideDetached
+ m.build()
+ m.focusRow(keepID, keepHeader)
+ return m, m.previewCmd()
+
case "ctrl+x":
if r, ok := m.current(); ok && r.kind != kindHeader {
if err := action.Kill(r.win, m.opt.Suffix); err != nil {
@@ -637,6 +756,21 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
+// focusRow puts the cursor back on a window's row after the rows are rebuilt.
+// A no-op when that row is gone, leaving refilter's clamp to decide.
+func (m *Model) focusRow(id string, header bool) {
+ if id == "" {
+ return
+ }
+ for i, vi := range m.view {
+ if r := m.rows[vi]; r.win.ID == id && (r.kind == kindHeader) == header {
+ m.cursor = i
+ m.ensureVisible()
+ return
+ }
+ }
+}
+
func (m *Model) move(d int) {
m.cursor += d
if m.cursor < 0 {
@@ -724,6 +858,8 @@ func glyph(st model.Status) string {
}
}
+// listWidth is the whole list column, scrollbar gutter included. The preview
+// is sized from what is left of innerW after it.
func (m *Model) listWidth() int {
if m.opt.Preview && m.sideBySide() {
return m.innerW()*55/100 - 2
@@ -731,13 +867,23 @@ func (m *Model) listWidth() int {
return m.innerW()
}
+// rowWidth is what a row's own content gets: the list column less the gutter.
+func (m *Model) rowWidth() int { return maxInt(20, m.listWidth()-scrollbarCells) }
+
func (m *Model) renderRow(r row, selected bool) string {
- lw := m.listWidth()
- // Leading space keeps the cursor off the frame border. Both variants are
- // exactly cursorCells wide on screen; the selected one just carries colour.
+ lw := m.rowWidth()
+ // ➤ (U+27A4), an arrowhead rather than the ▸ (U+25B8) triangle a collapsed
+ // session folds with. Different shape, not just a different weight, which
+ // is what keeps the two readable in the one place they meet — a selected,
+ // folded header — along with the two spaces between them.
+ //
+ // Both variants are exactly cursorCells wide on screen; the selected one
+ // just carries colour. Every candidate glyph was checked at 1 cell in tmux
+ // first: an ambiguous-width marker would shift every column on the selected
+ // row and nowhere else.
cursor := " "
if selected {
- cursor = " " + cPrompt.Render("▸ ")
+ cursor = cCursor.Render("➤") + " "
}
if r.kind == kindHeader {
@@ -780,7 +926,7 @@ func (m *Model) renderRow(r row, selected bool) string {
badgeCol := strings.Repeat(" ", maxInt(0, m.badgeW-ansi.StringWidth(badge))) + badge
fixed := cursorCells + ansi.StringWidth(indent) + m.badgeW + rightMargin
if r.kind == kindPane {
- fixed += 1 + 1 + 4 // glyph, active marker, four single spaces
+ fixed += 1 + 1 + 5 // glyph, active marker, five single spaces
} else {
fixed += 1 + 4 + 2 + 5 // glyph, "NNp ", flags, five single spaces
}
@@ -795,7 +941,7 @@ func (m *Model) renderRow(r row, selected bool) string {
pathW = 8
}
- var label, name, extra string
+ var label, name string
if r.kind == kindPane {
// In the tree the session is already on the header, so a pane row shows
// only its own coordinates.
@@ -804,10 +950,16 @@ func (m *Model) renderRow(r row, selected bool) string {
label = r.win.Session + ":" + label
}
name = strings.TrimSpace(r.pane.Cmd)
+
+ // The active-pane marker gets a column of its own, reserved on every
+ // pane row. Rendered flush against the glyph it read as one smudged
+ // symbol, and appearing only on the active row it shifted that row's
+ // every other column one cell right of its neighbours'.
+ marker := " "
if r.pane.Active {
- extra = "*"
+ marker = cFlag.Render("*")
}
- return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+extra+" "+
+ return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+" "+marker+" "+
pad(label, labelW)+" "+cName.Render(pad(name, nameW))+" "+
cDim.Render(pad(padLeft(tilde(r.pane.Path), pathW), pathW))+" "+
badgeCol, lw)
@@ -873,18 +1025,25 @@ func (m *Model) View() string {
lines := make([]string, 0, h)
for i := m.offset; i < len(m.view) && i < m.offset+h; i++ {
r := m.rows[m.view[i]]
- line := padToWidth(m.renderRow(r, i == m.cursor), m.listWidth())
+ line := padToWidth(m.renderRow(r, i == m.cursor), m.rowWidth())
if i == m.cursor {
line = cSel.Render(line)
}
lines = append(lines, line)
}
if len(lines) == 0 {
- lines = append(lines, cDim.Render(padToWidth(" no matches", m.listWidth())))
+ // An empty list with no query typed is the filter's doing, not the
+ // query's — say which, or it reads as "you have no sessions".
+ msg := " no matches"
+ if m.opt.HideDetached && strings.TrimSpace(m.query) == "" {
+ msg = " nothing attached — ^e shows detached sessions"
+ }
+ lines = append(lines, cDim.Render(padToWidth(msg, m.rowWidth())))
}
for len(lines) < h {
- lines = append(lines, strings.Repeat(" ", m.listWidth()))
+ lines = append(lines, strings.Repeat(" ", m.rowWidth()))
}
+ lines = withScrollbar(lines, m.rowWidth(), len(m.view), m.offset)
list := strings.Join(lines, "\n")
// ── body: list, optionally beside the preview ─────────────────────────
@@ -974,10 +1133,3 @@ func maxInt(a, b int) int {
}
return b
}
-
-func minInt(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go
index 817429c..2b3e7db 100644
--- a/internal/ui/ui_test.go
+++ b/internal/ui/ui_test.go
@@ -62,12 +62,14 @@ func badgeCol(s string) int {
return runewidth.StringWidth(plain[:i])
}
-func TestRowsNeverExceedListWidth(t *testing.T) {
+// Against rowWidth, not listWidth: a row that spilled into the scrollbar gutter
+// would push the bar off the frame.
+func TestRowsNeverExceedRowWidth(t *testing.T) {
m := newTestModel(t)
for _, r := range m.rows {
for _, sel := range []bool{false, true} {
- if got := ansi.StringWidth(m.renderRow(r, sel)); got > m.listWidth() {
- t.Errorf("row %q width %d exceeds list width %d", r.group, got, m.listWidth())
+ if got := ansi.StringWidth(m.renderRow(r, sel)); got > m.rowWidth() {
+ t.Errorf("row %q width %d exceeds row width %d", r.group, got, m.rowWidth())
}
}
}