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
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ on:
description: "Package to publish (only used when run directly, not via release)"
type: choice
default: context-tree
options: [context-tree]
options: [context-tree, git-stats]

permissions:
id-token: write # mint the OIDC token npm verifies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:
description: "Package to release"
type: choice
default: context-tree
options: [context-tree]
options: [context-tree, git-stats]
bump:
description: "Version bump relative to the latest <package>-v* tag"
type: choice
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ plugins. Each publishable package lives under `packages/<name>/` with its own
| Package | Description |
|---|---|
| [`packages/context-tree`](packages/context-tree/README.md) | Pi-style context tree for OpenCode: branch, merge, crop, undo, plus a trajectory view |
| [`packages/git-stats`](packages/git-stats/README.md) | Sidebar card: working-tree diff figures, plus a GitHub-coloured chip per pull request the session touched |

To add a new plugin, create `packages/<name>/` with its own `package.json` and
release scripts, then add it to the `workflow_dispatch.inputs.package` choice
Expand Down
30 changes: 30 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions packages/git-stats/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Changelog

## 0.1.0 — 2026-09-10

First release.

A `sidebar_content` card, at order 450 so it sits directly above the host's own
**Modified Files** list, holding two things:

- **The working tree's figures** — `+123 -45 · 7 files` for the whole folder, from
`client.vcs.status()`, refreshed on OpenCode's `session.diff` and `session.idle`
events and on a 10 s tick so an edit made in another terminal shows up too. This
is deliberately *not* `state.session.diff()`, which accumulates only the files
the current session touched.

- **A chip per pull request the session touched** — `#20 Merged`, painted with
GitHub's own state colours (`#59636e` draft, `#1f883d` open, `#cf222e` closed,
`#8250df` merged) and white text, the way the badge on the pull request page is.
Sightings come from completed tool parts (`state.session.messages()` +
`state.part()`), so a chip only exists because a command actually printed the
URL; the state behind it comes from `gh pr view --json state,isDraft`, the one
question OpenCode's API cannot answer. A merged PR is never re-fetched, and a
`gh` that is missing or logged out is reported in the card once instead of being
retried every tick.

Chips close: click the `×`, or run `/prs` to hide one, hide all, or bring the
hidden ones back. Dismissals live in `kv`, keyed per session, so they survive a
restart and do not leak between sessions. Each chip's label is also an OSC 8
hyperlink to the pull request.

Two limits worth knowing about. Only `github.com` is trusted unless a host is
named in the plugin's `hosts` option: the URLs are found in *tool output*, which
can carry a `github.evil.example` a fetched page put there, and the host goes
straight to `gh --repo`, which would treat an unknown one as Enterprise and send
it a request with an Enterprise token attached. And at most 24 chips are tracked
per session, so a `gh pr list --json url` cannot mint a chip — and a `gh`
process — per pull request in the repository. Failing `gh` calls back off
exponentially to about 16 minutes, and reset at the end of a turn in case the
user has just installed or logged into it.
21 changes: 21 additions & 0 deletions packages/git-stats/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Naveen (navbytes)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
134 changes: 134 additions & 0 deletions packages/git-stats/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<div align="center">

# opencode-git-stats

**What the working tree looks like right now, and a GitHub-coloured chip for every
pull request your [OpenCode](https://opencode.ai) session touched.**

[![npm](https://img.shields.io/npm/v/opencode-git-stats?color=cb3837&logo=npm)](https://www.npmjs.com/package/opencode-git-stats)
[![CI](https://github.com/navbytes/opencode-tree/actions/workflows/ci.yml/badge.svg)](https://github.com/navbytes/opencode-tree/actions/workflows/ci.yml)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![OpenCode](https://img.shields.io/badge/OpenCode-%E2%89%A5%201.18-black)](https://opencode.ai)

</div>

```
Git Stats
⎇ fix-release-prerelease-bump
+123 -45 · 7 files
#20 Merged × #21 Draft ×
```


- **Working-tree figures** — total additions, deletions and changed files for the
whole folder, not just the files this session edited. They come from OpenCode's
own VCS status endpoint, so they agree with `git status`.
- **Pull request chips** — every GitHub PR whose URL turned up in this session's
tool output, with GitHub's own state colour: grey **Draft**, green **Open**,
red **Closed**, purple **Merged**. States refresh on their own as the session
runs, so a PR you merge mid-session turns purple without a restart.
- **Close a chip** — click the `×` beside it, or run `/prs` to hide one, hide them
all, or bring the hidden ones back. Dismissals are per session and survive a
restart.
- **Open the PR** — each chip is a terminal hyperlink, so clicking its label opens
the pull request in your browser (in terminals that support OSC 8; elsewhere the
chip is simply plain text).

## Install

This is a **TUI** plugin, so it goes in `tui.json` (not `opencode.json`):

`~/.config/opencode/tui.json`, or `.opencode/tui.json` in a project —

```json
{
"$schema": "https://opencode.ai/tui.json",
"plugin": ["opencode-git-stats@latest"]
}
```

Restart OpenCode. The card appears in the sidebar, just above **Modified Files**.

Requires OpenCode ≥ 1.18. Chip *states* additionally need the
[GitHub CLI](https://cli.github.com) on your `PATH` and logged in (`gh auth
login`); without it the chips still appear, uncoloured, and the card says why.

## What counts as "a PR this session touched"

Any `https://github.com/<owner>/<repo>/pull/<number>` URL that appears in the
output — or the command line — of a tool call in this session. In practice that
is the URL `gh pr create` prints, plus anything a later `gh pr view` or
`gh pr merge` names. Prose in the assistant's replies is deliberately *not*
scanned: only what a command actually produced.

Pull requests the session **created** (the URL came out of a `gh pr create`) sort
ahead of ones it merely referenced. At most 24 chips are tracked per session, so
a stray `gh pr list --json url` cannot fill the sidebar.

### GitHub Enterprise

Only `github.com` is trusted by default, and that is on purpose: the text being
scanned is tool output, which can come from a fetched page or a pasted file, and
the host in a URL is handed to `gh --repo <host>/…`. `gh` treats an unknown host
as Enterprise and would make a request to it — with your Enterprise token
attached. So `github.evil.example` and `github.com.evil.example` are ignored, and
a real Enterprise host has to be named:

```json
{
"plugin": [["opencode-git-stats@latest", { "hosts": ["github.acme.com"] }]]
}
```

## Commands

| Command | Slash | What it does |
|---|---|---|
| `gitstats.chips` | `/prs`, `/chips` | Hide one chip, hide all of them, or show the hidden ones again |

To put it on a key, add a binding for `gitstats.chips` in your OpenCode keybinds.

## How it works

The plugin is TUI-only and reads everything through OpenCode's official plugin
API:

| What | API |
|---|---|
| working-tree figures | `client.vcs.status()` |
| branch name | `state.vcs.branch` |
| PR sightings | `event.on("message.part.updated")`, plus a catch-up scan of `state.session.messages()` / `state.part()` |
| refresh triggers | `event.on("session.diff")`, `event.on("session.idle")`, a 10 s tick |
| dismissals | `kv` |
| the card | a `sidebar_content` slot at order 450 |

The one thing OpenCode cannot answer is whether a pull request is draft, open,
closed or merged, so the plugin shells out to `gh pr view --json state,isDraft`.
That is the only external call it makes, and it is spawned with an argv array —
never a shell string — for a host on the allow-list above.

A merged pull request is terminal and never re-fetched. Open and draft ones age
out after 90 seconds, closed ones after five minutes, and the end of every turn
marks them all due again. A `gh` call that fails backs that chip off
exponentially, up to about 16 minutes, so a missing or logged-out `gh` is not
re-spawned on every tick; the backoff clears at the end of a turn, in case you
have just installed it. Chips you have dismissed are never fetched at all.

## Development

```sh
bun install
bun run build # -> dist/tui.js
bun run typecheck
bun test
```

To try a local build, point `tui.json` at the built file:

```json
{ "plugin": ["/absolute/path/to/packages/git-stats/dist/tui.js"] }
```

## License

MIT
82 changes: 82 additions & 0 deletions packages/git-stats/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"name": "opencode-git-stats",
"version": "0.0.0-dev",
"private": false,
"type": "module",
"description": "OpenCode sidebar: working-tree diff stats and GitHub-coloured chips for the pull requests your session touched",
"license": "MIT",
"exports": {
"./tui": {
"types": "./src/tui/index.tsx",
"import": "./dist/tui.js"
}
},
"files": [
"dist",
"src",
"README.md",
"LICENSE",
"CHANGELOG.md"
],
"scripts": {
"build": "bun run scripts/build.ts",
"prepack": "bun run build",
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
"test": "bun test"
},
"engines": {
"opencode": ">=1.18.0"
},
"dependencies": {
"@opencode-ai/plugin": "1.18.26",
"@opencode-ai/sdk": "1.18.26"
},
"devDependencies": {
"@opentui/core": "^0.5.10",
"@opentui/keymap": "^0.5.10",
"@opentui/solid": "^0.5.10",
"@types/bun": "latest",
"solid-js": "^1.9.12",
"typescript": "^5.8.2"
},
"peerDependencies": {
"@opentui/core": ">=0.5.10",
"@opentui/keymap": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"solid-js": ">=1.9.12"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"author": "Naveen (navbytes)",
"repository": {
"type": "git",
"url": "git+https://github.com/navbytes/opencode-tree.git",
"directory": "packages/git-stats"
},
"homepage": "https://github.com/navbytes/opencode-tree/tree/main/packages/git-stats#readme",
"keywords": [
"opencode",
"opencode-plugin",
"git",
"diff",
"github",
"pull-request",
"sidebar",
"tui"
],
"publishConfig": {
"access": "public"
}
}
23 changes: 23 additions & 0 deletions packages/git-stats/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* One entry point: src/tui/index.tsx -> dist/tui.js (Solid JSX, via @opentui/solid's
* bun plugin). @opencode-ai/*, @opentui/* and solid-js are provided by the host TUI.
*/
import solidPlugin from "@opentui/solid/bun-plugin"
import pkg from "../package.json"

const result = await Bun.build({
entrypoints: ["src/tui/index.tsx"],
outdir: "dist",
target: "bun",
format: "esm",
naming: "tui.js",
plugins: [solidPlugin],
external: ["@opencode-ai/*", "@opentui/*", "solid-js", "solid-js/*"],
define: { __GIT_STATS_VERSION__: JSON.stringify(pkg.version) },
})
if (!result.success) {
console.error("build failed: src/tui/index.tsx")
for (const log of result.logs) console.error(log)
process.exit(1)
}
for (const output of result.outputs) console.log(`${output.path} ${output.size}B`)
Loading
Loading