Skip to content

Workstreams

A workstream is one unit of work: usually a git worktree and branch, the panes you work in, and the commands that set them up. You open a workstream from a template, and it appears as a tab. This page covers templates, ws new, where worktrees live, the Neovim that each tab gets, and the pull request status wrkstrms shows for each workstream.

The prefix key is Ctrl-Space: prefix n means press Ctrl-Space, let go, then press n. Every key is in Keys and mouse.

A template describes how to open a workstream: which repo to make the worktree from, which panes to start, and how to lay them out.

Templates are TOML files in ~/.config/ws/templates/ (or $XDG_CONFIG_HOME/ws/templates/). Every *.toml file in that folder is read, and one file can hold several templates. If two files define a template with the same name, the file that sorts last by name wins.

List the templates ws can see:

Terminal window
ws templates

It prints each template’s name, repo and panes. A file with a mistake stops ws from loading templates, and the error names the file.

Each template is a [template.<name>] table, with one [[template.<name>.pane]] entry per pane:

[template.design-system-pr]
repo = "~/code/design-system" # worktrees are made from this checkout
base = "main" # new branches start here
workspace = "Design System" # the tab goes in this workspace
setup = ["pnpm install"] # runs when the tab opens, in the shell pane
[[template.design-system-pr.pane]]
name = "claude"
cmd = "claude"
agent = "claude"
model = "opus" # becomes `claude --model opus`
[[template.design-system-pr.pane]]
name = "nvim"
cmd = "nvim --listen {nvim_socket}"
[[template.design-system-pr.pane]]
name = "shell"
cmd = "$SHELL"
[template.design-system-pr.layout]
preset = "main-left" # main-left | columns | rows

To start your own, copy it into a file in ~/.config/ws/templates/ and edit it. Leave out repo for a scratch workstream with no worktree or branch. A template needs at least one pane, and unknown keys are errors, so a typo like repoo is caught instead of ignored.

Every key, the layout presets, and the placeholders you can use in cmd ({worktree}, {branch}, {name}, {nvim_socket}) are in the template reference.

Every pane starts in the workstream’s directory with WS_WORKSTREAM set to the workstream’s name. A pane’s command replaces the shell that runs it, so the pane’s process is the program itself.

The setup commands run in the template’s first plain shell pane: the first pane with no agent whose cmd is $SHELL. If there isn’t one, ws adds a pane named setup for them. The setup prints ▶ ws setup, runs the commands, prints ✓ setup done or ✗ setup failed, and then leaves a shell open so you can read the output.

Terminal window
ws new <template> [name] [--branch <branch>] [--pr <number>] [--project <slug>]
Terminal window
ws new design-system-pr pr-101 # new branch pr-101 from the template's base
ws new design-system-pr review --branch feat/buttons # an existing branch
ws new design-system-pr --pr 101 # check out PR 101 as workstream pr-101
ws new design-system-pr pr-101 --project button-a11y # and link it to a project

The name is also the branch name, unless you pass --branch. It can use letters, digits, ., _, - and /, can’t start with . or -, and can’t contain ...

Flag Effect
--branch <branch> Use this branch instead of one named after the workstream. An existing branch is checked out; otherwise it’s created from base.
--pr <number> Check out this pull request with gh pr checkout, forks included. The name defaults to pr-<number>. The template needs a repo.
--project <slug> Link the workstream’s tab to this project. The project must exist. See Projects.

Where it opens:

  • Inside wrkstrms, ws new asks the wrkstrms your pane belongs to to open the workstream, as a new tab in the template’s workspace. If a workstream with the same template and name is already open, it jumps to that tab instead.
  • Outside wrkstrms, ws new checks that the template exists, then starts wrkstrms (or attaches to the running server) with the workstream open. With --local, it runs in this terminal without the background server.

The tab is named after the workstream. When it opens, the top bar says what happened: created worktree on <branch>, reopened worktree on <branch>, or no repo.

Inside wrkstrms, prefix n asks for a workstream in one line. “New workstream…” in the right-click menus of workspaces, tabs and empty chrome opens the same dialog.

You type Opens
template name Workstream name on a new branch name
template name branch Workstream name on branch
template #101 Pull request 101, as workstream pr-101
template #101 name Pull request 101, as workstream name

Worktrees go under ~/.local/share/ws/worktrees/<repo>/<name> (or under $XDG_DATA_HOME/ws/worktrees/). <repo> is the last part of the template’s repo path, and a / in the workstream’s name becomes - in the folder name. ws drives git through the git CLI, so your own git config and credentials apply.

When you open a workstream:

  • If the folder doesn’t exist, ws creates the worktree. The branch is checked out if it exists, or created from base if it doesn’t.
  • If the folder exists and is a worktree of the same repo, ws reuses it. This is how you reopen a workstream after closing its tab.
  • If the folder exists but isn’t a worktree of that repo, ws stops with an error rather than touch it.

Closing a workstream’s tab leaves its worktree in place. To remove it, use ws done (below).

For --pr, ws adds a detached worktree and runs gh pr checkout <number> in it. If the checkout fails, the new worktree is removed again. The workstream’s branch is whatever gh checked out.

A scratch workstream (a template with no repo) has no worktree and no branch. Its panes start in wrkstrms’s working directory.

Each tab has one Neovim, and everything that opens a file sends it there.

  • prefix e focuses the tab’s Neovim. If the tab doesn’t have one, ws starts nvim --listen <socket> in a split to the right. In a workstream tab it starts in the worktree.
  • A template pane whose cmd contains {nvim_socket} becomes the tab’s Neovim, for example nvim --listen {nvim_socket}.
  • “Editor (nvim)” in a pane’s or tab’s right-click menu does the same as prefix e.

The socket for a workstream’s Neovim is named after the workstream, in ws’s socket directory ($XDG_RUNTIME_DIR/ws/, or ws-<uid> in the system temp directory).

From any pane, ws open opens a file in the Neovim of that pane’s tab and focuses it:

Terminal window
ws open app/models/user.rb # open the file
ws open app/models/user.rb:42 # and go to line 42
ws open src/main.rs:10:5 # a column is accepted but ignored

The path is relative to the directory you run ws open in. If the tab has no Neovim running, ws starts one in a split to the right with the file open. Outside wrkstrms, ws open runs nvim on the file directly.

For each workstream tab with a repo and a branch, ws asks gh about the branch’s pull request in the background with gh pr view. ws never handles GitHub tokens; it uses your gh login.

How often it checks:

Tab Checked every
The tab on screen 20 seconds
Other tabs 60 seconds
Tabs you haven’t looked at in 24 hours Not checked until you look again

If gh isn’t installed, ws turns PR status off and says so once.

  • Status line: the branch (⎇ feat/buttons), then the PR number and its checks, such as #101 CI ✓.
  • Sidebar, current workspace: each tab chip shows its PR number and checks, such as #101 ✓.
  • Sidebar, other workspaces: the second line shows the active tab’s branch and PR badge.

The badge starts with the PR number, then adds what applies:

Part Meaning
✓ All checks passed
✗ A check failed, timed out, was cancelled or needs action
… Checks are still running
(nothing) The PR has no checks
merged / closed The PR’s state, when it isn’t open
approved / changes requested The review decision

For example, #101 ✓ approved or #87 ✗ changes requested. A single failing check makes the whole badge ✗.

prefix p opens the current workstream’s pull request in your browser (gh pr view <branch> --web). “Open PR” in the tab’s right-click menu does the same for workstreams with a branch. In a tab that isn’t a workstream, the top bar says “not a workstream tab”.

When a workstream’s work is merged (or at least pushed), ws done closes its tab and removes its worktree in one step:

Terminal window
ws done # from any pane in the workstream's tab
ws done pr-101 # by name, from anywhere

For a workstream with a branch, the tab’s right-click menu has the same thing as “Done: close, remove worktree…”, which asks first.

Before removing anything, ws checks the worktree and refuses if either is true:

  • Uncommitted changes: git status --porcelain lists anything, including untracked files.
  • Unpushed commits: the worktree’s current commit isn’t on any remote branch (git branch -r --contains HEAD is empty). A commit that was pushed, or merged into a branch that was pushed, passes.

The refusal names both problems, for example:

pr-101 isn't done: 1 uncommitted change; feat/login has commits that aren't pushed or merged (--force removes it anyway)

ws done --force skips the checks and removes the worktree even with changes in it (git worktree remove --force). The menu item never forces.

What happens when it goes ahead:

  1. The tab closes, stopping every process in it (its agents, Neovim, dev servers), the same as closing it by hand.
  2. About a second and a half later, once those processes have stopped, git worktree remove removes the folder. The status line says pr-101 is done: removed <path>; the branch stays, or why it couldn’t remove it.

The branch is never deleted, locally or on the remote, and a project linked to the tab stays. A scratch workstream (no repo) just closes. ws done can’t close the last tab in ws.