@oscbacon/tungsten 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oscar Baracos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # tungsten
2
+
3
+ A terminal inbox for a GitHub repo's pull requests.
4
+
5
+ <img width="2502" height="1350" alt="image" src="https://github.com/user-attachments/assets/00dfb440-843e-4c74-ab2b-80184984e309" />
6
+
7
+ Lists a repo's PRs by bucket, and allow you to open the PR in your browser
8
+
9
+ <img width="1222" height="720" alt="tungsten-demo" src="https://github.com/user-attachments/assets/81b35e8e-8eca-4ef0-bd14-66a9ba8c1505" />
10
+
11
+ ## Requirements
12
+
13
+ - [Bun](https://bun.sh)
14
+ - [GitHub CLI](https://cli.github.com), logged in (`gh auth login`); 2.98 or newer for `w`
15
+
16
+ ## Install
17
+
18
+ git clone git@github.com:OscBacon/tungsten.git
19
+ cd tungsten
20
+ bun install
21
+ bun link # links `tungsten` into ~/.bun/bin; make sure that's on your PATH
22
+
23
+ ## Usage
24
+
25
+ tungsten # repo from the current clone's git remotes, like gh
26
+ tungsten -R cli/cli # or pick one: [HOST/]OWNER/REPO
27
+ tungsten --demo # sample data, no clone or network
28
+ tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
29
+
30
+ ## Buckets
31
+
32
+ - Needs your review
33
+ - Returned to you
34
+ - Approved
35
+ - Waiting for reviewers
36
+ - Drafts
37
+ - Open
38
+ - Recently merged
39
+
40
+ Each shows up to 25 PRs and the total count.
41
+
42
+ ## Keys
43
+
44
+ | Key | Action |
45
+ |----------|--------------------|
46
+ | ↑↓ / j k | move |
47
+ | t | collapse bucket |
48
+ | enter | open PR in browser |
49
+ | / | search |
50
+ | c | check out PR |
51
+ | w | open PR worktree |
52
+ | r | refresh |
53
+ | q | quit |
54
+
55
+ ## Search
56
+
57
+ `/` opens a prompt; `enter` searches GitHub, `esc` clears the search and goes back to the
58
+ PRs from before it without refetching them (`r` refreshes them).
59
+
60
+ fix token words in the title
61
+ @login PRs by that author (several @logins: any of them)
62
+ #123 that PR, looked up directly
63
+
64
+ ## Checking out
65
+
66
+ Both keys work from a clone of the repo on screen, and hand the PR to `gh pr checkout`,
67
+ so forks and remotes behave as they do there.
68
+
69
+ - `c` checks the PR out in the current clone.
70
+ - `w` opens the worktree that already has the PR's branch, or creates one at
71
+ `~/worktrees/<repo>/<branch>` (slashes in the branch become dashes). Inside tmux it opens a
72
+ new window there; otherwise it shows the path.
73
+
74
+ The app stays on screen while gh works, so git can't prompt: a checkout that needs an ssh
75
+ passphrase, a new host key or a password fails with that error instead. Load the key into
76
+ ssh-agent or set up a credential helper, or run `tungsten --show-git-prompt` to answer the
77
+ prompts (the app is hidden while they run).
package/bun.lock ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "tungsten",
7
+ "dependencies": {
8
+ "@opentui/core": "^0.5.11",
9
+ "@opentui/react": "^0.5.11",
10
+ "react": "^19.3.0",
11
+ },
12
+ "devDependencies": {
13
+ "@types/bun": "^1.4.2",
14
+ "@types/react": "^19.3.0",
15
+ },
16
+ },
17
+ },
18
+ "packages": {
19
+ "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="],
20
+
21
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="],
22
+
23
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="],
24
+
25
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="],
26
+
27
+ "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="],
28
+
29
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="],
30
+
31
+ "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="],
32
+
33
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="],
34
+
35
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="],
36
+
37
+ "@opentui/react": ["@opentui/react@0.5.11", "", { "dependencies": { "@opentui/core": "0.5.11", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-ACH6Kpl3/O1zdNL0sZtzrnxfw0+auJu4eefIbPTAPuNEessyuhd2xvV4agrdWRJCeogywS7er5k7uS/8YRWnZg=="],
38
+
39
+ "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
40
+
41
+ "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
42
+
43
+ "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
44
+
45
+ "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
46
+
47
+ "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
48
+
49
+ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
50
+
51
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
52
+
53
+ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
54
+
55
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
56
+
57
+ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
58
+
59
+ "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
60
+
61
+ "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
62
+
63
+ "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
64
+
65
+ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
66
+
67
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
68
+
69
+ "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
70
+
71
+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
72
+
73
+ "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
74
+
75
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
76
+
77
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
78
+
79
+ "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
80
+
81
+ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
82
+
83
+ "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
84
+ }
85
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@oscbacon/tungsten",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "bin": {
6
+ "tungsten": "src/index.tsx"
7
+ },
8
+ "devDependencies": {
9
+ "@types/bun": "^1.4.2",
10
+ "@types/react": "^19.3.0"
11
+ },
12
+ "dependencies": {
13
+ "@opentui/core": "^0.5.11",
14
+ "@opentui/react": "^0.5.11",
15
+ "react": "^19.3.0"
16
+ },
17
+ "scripts": {
18
+ "start": "bun src/index.tsx",
19
+ "test": "bun test"
20
+ }
21
+ }
@@ -0,0 +1,287 @@
1
+ import { expect, test } from "bun:test"
2
+ import { testRender } from "@opentui/react/test-utils"
3
+ import { act } from "react"
4
+ import { App, type Actions } from "./app"
5
+ import { demoBuckets, type Bucket } from "./data"
6
+ import { parseRepo } from "./repo"
7
+
8
+ const press = async (s: Awaited<ReturnType<typeof testRender>>, fn: () => unknown = () => {}) => {
9
+ await act(async () => { await fn(); await new Promise(r => setTimeout(r, 10)) })
10
+ await s.renderOnce()
11
+ }
12
+
13
+ // A lone ESC is only reported once the input parser gives up waiting for a longer sequence.
14
+ const escape = async (s: Awaited<ReturnType<typeof testRender>>) => {
15
+ await press(s, () => s.mockInput.pressEscape())
16
+ await press(s)
17
+ }
18
+
19
+ const repo = parseRepo("withgraphite/monologue")
20
+ const noActions: Actions = { checkout: async () => "", worktree: async () => "" }
21
+
22
+ test("arrows move, t collapses the current bucket, cursor lands on its header", async () => {
23
+ const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={noActions} />, { width: 110, height: 20 })
24
+ try {
25
+ await press(s) // let the load resolve
26
+ await press(s, () => s.mockInput.pressArrow("down"))
27
+ await press(s, () => s.mockInput.pressArrow("down"))
28
+ expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
29
+ await press(s, () => s.mockInput.pressKey("t"))
30
+ let frame = s.captureCharFrame()
31
+ expect(frame).toContain("▸ Needs your review")
32
+ expect(frame).not.toContain("refactor")
33
+ await press(s, () => s.mockInput.pressArrow("down")) // header -> "Returned to you"
34
+ await press(s, () => s.mockInput.pressArrow("down")) // -> "Approved" header
35
+ await press(s, () => s.mockInput.pressArrow("down")) // -> first approved PR
36
+ expect(s.captureCharFrame()).toMatch(/▌ • ✓ \[Snyk\]/)
37
+ // scroll: walk to the bottom of a short viewport
38
+ for (let i = 0; i < 12; i++) await press(s, () => s.mockInput.pressArrow("down"))
39
+ frame = s.captureCharFrame()
40
+ console.log(frame)
41
+ expect(frame).toMatch(/▌ • ◆ fix: throw/)
42
+ expect(frame.trimEnd().split("\n").at(-2)).toMatch(/^╰/) // last bucket fully visible
43
+ } finally { s.renderer.destroy() }
44
+ })
45
+
46
+ test("r refreshes: loading state, cursor follows its PR, a failed refresh keeps the last data", async () => {
47
+ const withoutFirstPr = (): Bucket[] => {
48
+ const b = demoBuckets(repo)
49
+ b[0]!.prs.shift()
50
+ return b
51
+ }
52
+ let release!: (b: Bucket[]) => void
53
+ const loads: (() => Promise<Bucket[]>)[] = [
54
+ () => new Promise(r => (release = r)),
55
+ async () => withoutFirstPr(),
56
+ async () => { throw new Error("HTTP 401: Bad credentials") },
57
+ ]
58
+ let calls = 0
59
+ const load = () => loads[calls++]!()
60
+
61
+ const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
62
+ try {
63
+ await press(s)
64
+ expect(s.captureCharFrame()).toContain("Loading pull requests…")
65
+ await press(s, () => release(demoBuckets(repo)))
66
+ await press(s, () => s.mockInput.pressArrow("down"))
67
+ await press(s, () => s.mockInput.pressArrow("down"))
68
+ expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
69
+
70
+ await press(s, () => s.mockInput.pressKey("r"))
71
+ let frame = s.captureCharFrame()
72
+ expect(frame).not.toContain("only find upstack")
73
+ expect(frame).toMatch(/▌ • ◷ refactor/) // same PR, now the first row
74
+
75
+ await press(s, () => s.mockInput.pressKey("r"))
76
+ frame = s.captureCharFrame()
77
+ expect(frame).toContain("✗ HTTP 401: Bad credentials")
78
+ expect(frame).toMatch(/▌ • ◷ refactor/)
79
+ expect(calls).toBe(3)
80
+ } finally { s.renderer.destroy() }
81
+ })
82
+
83
+ test("a PR listed in two buckets selects and moves from the copy the cursor is on", async () => {
84
+ const dup = demoBuckets(repo)[0]!.prs[0]!
85
+ const buckets: Bucket[] = [
86
+ { name: "Needs your review", prs: [dup] },
87
+ { name: "Open", prs: [dup, { ...dup, number: 999, title: "second open pull request" }] },
88
+ ]
89
+ const s = await testRender(<App repo={repo} load={async () => buckets} actions={noActions} />, { width: 110, height: 20 })
90
+ try {
91
+ await press(s)
92
+ // header -> the copy in "Needs your review" -> "Open" header -> the copy in "Open"
93
+ for (let i = 0; i < 3; i++) await press(s, () => s.mockInput.pressArrow("down"))
94
+ const lines = s.captureCharFrame().split("\n")
95
+ const openHeader = lines.findIndex(l => l.includes("▾ Open"))
96
+ expect(lines[openHeader + 1]).toContain("▌") // the copy under Open, not the one above
97
+ expect(lines.slice(0, openHeader).join("\n")).not.toContain("▌")
98
+
99
+ await press(s, () => s.mockInput.pressArrow("down"))
100
+ expect(s.captureCharFrame()).toMatch(/▌ • ◷ second open pull request/)
101
+ } finally { s.renderer.destroy() }
102
+ })
103
+
104
+ test("footer lists / c w between enter and r", async () => {
105
+ const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={noActions} />, { width: 110, height: 20 })
106
+ try {
107
+ await press(s)
108
+ expect(s.captureCharFrame().trimEnd().split("\n").at(-1)).toMatch(/enter open PR {3}\/ search {3}c checkout {3}w worktree {3}r refresh {3}q quit/)
109
+ } finally { s.renderer.destroy() }
110
+ })
111
+
112
+ test("/ types a search that only loads on enter; esc clears it", async () => {
113
+ const searches: (string | undefined)[] = []
114
+ const load = async (search?: string) => {
115
+ searches.push(search)
116
+ return search ? [{ name: "Open", prs: demoBuckets(repo)[0]!.prs.slice(1, 2) }] : demoBuckets(repo)
117
+ }
118
+ const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
119
+ try {
120
+ await press(s)
121
+ await press(s, () => s.mockInput.pressKey("/"))
122
+ await press(s, () => s.mockInput.typeText("refactor rtq"))
123
+ await press(s, () => s.mockInput.pressBackspace())
124
+ let frame = s.captureCharFrame()
125
+ expect(frame).toContain("/ refactor rt█")
126
+ expect(frame).toContain("only find upstack") // nothing filtered while typing
127
+ expect(searches).toEqual([undefined]) // and r, t, q were text, not keys
128
+
129
+ await press(s, () => s.mockInput.pressEnter())
130
+ frame = s.captureCharFrame()
131
+ expect(searches).toEqual([undefined, "refactor rt"])
132
+ expect(frame).toMatch(/Inbox {2}\/ refactor rt/)
133
+ expect(frame).not.toContain("only find upstack")
134
+ expect(frame).toContain("refactor: add logic")
135
+ // The footer is back, with a way out in place of "/ search".
136
+ expect(frame.trimEnd().split("\n").at(-1)).toMatch(/^ esc clear search {3}↑↓ move .* enter open PR {3}c checkout .* q quit/)
137
+
138
+ await press(s, () => s.mockInput.pressKey("r")) // refresh keeps the search
139
+ expect(searches.at(-1)).toBe("refactor rt")
140
+
141
+ await press(s, () => s.mockInput.pressEscape())
142
+ await press(s)
143
+ expect(searches).toEqual([undefined, "refactor rt", "refactor rt"]) // cleared without a fetch
144
+ frame = s.captureCharFrame()
145
+ expect(frame).toContain("only find upstack")
146
+ expect(frame.trimEnd().split("\n").at(-1)).toMatch(/^ ↑↓ move .* enter open PR {3}\/ search {3}c checkout/)
147
+
148
+ // esc in the prompt also clears an applied search
149
+ await press(s, () => s.mockInput.pressKey("/"))
150
+ await press(s, () => s.mockInput.typeText("@someone"))
151
+ await press(s, () => s.mockInput.pressEnter())
152
+ expect(searches.at(-1)).toBe("@someone")
153
+ await press(s, () => s.mockInput.pressKey("/"))
154
+ expect(s.captureCharFrame()).toContain("/ @someone█") // reopens with the current search
155
+ await press(s, () => s.mockInput.pressEscape())
156
+ await press(s)
157
+ expect(searches.at(-1)).toBe("@someone")
158
+ expect(s.captureCharFrame()).toContain("only find upstack")
159
+ } finally { s.renderer.destroy() }
160
+ })
161
+
162
+ // A load per call that the test resolves by hand, recording what each was for.
163
+ function manualLoads() {
164
+ const calls: { search?: string; resolve: (b: Bucket[]) => void }[] = []
165
+ const load = (search?: string) => new Promise<Bucket[]>(resolve => calls.push({ search, resolve }))
166
+ return { load, calls }
167
+ }
168
+ const titled = (title: string): Bucket[] => {
169
+ const b = demoBuckets(repo)
170
+ b[0]!.prs[0] = { ...b[0]!.prs[0]!, title }
171
+ return b
172
+ }
173
+ const results = (title: string): Bucket[] => [{ name: "Search results", prs: [{ ...demoBuckets(repo)[0]!.prs[1]!, title }] }]
174
+
175
+ test("clearing a search shows the last unsearched data at once, from the top; r refetches it", async () => {
176
+ const { load, calls } = manualLoads()
177
+ const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
178
+ const search = async (q: string) => {
179
+ await press(s, () => s.mockInput.pressKey("/"))
180
+ await press(s, () => s.mockInput.typeText(q))
181
+ await press(s, () => s.mockInput.pressEnter())
182
+ }
183
+ try {
184
+ await press(s, () => calls[0]!.resolve(titled("first unsearched load")))
185
+ await press(s, () => s.mockInput.pressArrow("down"))
186
+ await press(s, () => s.mockInput.pressArrow("down"))
187
+ expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
188
+
189
+ await search("docs")
190
+ await press(s, () => calls[1]!.resolve(results("docs result")))
191
+ expect(s.captureCharFrame()).toContain("docs result")
192
+
193
+ await escape(s)
194
+ let frame = s.captureCharFrame()
195
+ expect(calls.length).toBe(2)
196
+ expect(frame).toContain("first unsearched load")
197
+ expect(frame).not.toContain("loading…")
198
+ expect(frame).not.toContain("▌") // back at the top, on the first header
199
+
200
+ // A search still loading when it's cleared can't land afterwards.
201
+ await search("slow")
202
+ await escape(s)
203
+ await press(s, () => calls[2]!.resolve(results("late search result")))
204
+ frame = s.captureCharFrame()
205
+ expect(frame).toContain("first unsearched load")
206
+ expect(frame).not.toContain("late search result")
207
+
208
+ await press(s, () => s.mockInput.pressKey("r"))
209
+ expect(calls.map(c => c.search)).toEqual([undefined, "docs", "slow", undefined])
210
+ await press(s, () => calls[3]!.resolve(titled("refreshed unsearched load")))
211
+ expect(s.captureCharFrame()).toContain("refreshed unsearched load")
212
+ } finally { s.renderer.destroy() }
213
+ })
214
+
215
+ test("an unsearched load still running when a search starts is dropped, so clearing fetches", async () => {
216
+ const { load, calls } = manualLoads()
217
+ const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
218
+ try {
219
+ await press(s)
220
+ await press(s, () => s.mockInput.pressKey("/"))
221
+ await press(s, () => s.mockInput.typeText("docs"))
222
+ await press(s, () => s.mockInput.pressEnter())
223
+ await press(s, () => calls[0]!.resolve(titled("dropped unsearched load")))
224
+ await press(s, () => calls[1]!.resolve(results("docs result")))
225
+ expect(s.captureCharFrame()).not.toContain("dropped unsearched load")
226
+
227
+ await escape(s)
228
+ expect(calls.map(c => c.search)).toEqual([undefined, "docs", undefined])
229
+ await press(s, () => calls[2]!.resolve(titled("fresh unsearched load")))
230
+ expect(s.captureCharFrame()).toContain("fresh unsearched load")
231
+ } finally { s.renderer.destroy() }
232
+ })
233
+
234
+ test("collapsed buckets are tracked by name across searches", async () => {
235
+ const load = async (search?: string) => (search ? results("docs result") : demoBuckets(repo))
236
+ const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 30 })
237
+ try {
238
+ await press(s)
239
+ for (let i = 0; i < 5; i++) await press(s, () => s.mockInput.pressArrow("down")) // "Approved" header
240
+ await press(s, () => s.mockInput.pressKey("t"))
241
+ expect(s.captureCharFrame()).toContain("▸ Approved")
242
+
243
+ await press(s, () => s.mockInput.pressKey("/"))
244
+ await press(s, () => s.mockInput.typeText("docs"))
245
+ await press(s, () => s.mockInput.pressEnter())
246
+ await press(s, () => s.mockInput.pressKey("t")) // collapse "Search results", the first bucket
247
+ expect(s.captureCharFrame()).toContain("▸ Search results")
248
+
249
+ await escape(s)
250
+ const frame = s.captureCharFrame()
251
+ expect(frame).toContain("▾ Needs your review") // not collapsed by position
252
+ expect(frame).toContain("▸ Approved")
253
+ } finally { s.renderer.destroy() }
254
+ })
255
+
256
+ test("c and w act on the PR under the cursor and show the outcome", async () => {
257
+ const calls: string[] = []
258
+ let finish!: () => void
259
+ const actions: Actions = {
260
+ checkout: async pr => {
261
+ calls.push(`checkout #${pr.number}`)
262
+ await new Promise<void>(r => (finish = r))
263
+ return `Checked out PR #${pr.number}`
264
+ },
265
+ worktree: async pr => { calls.push(`worktree #${pr.number}`); throw new Error("w needs gh 2.98 or newer") },
266
+ }
267
+ const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={actions} />, { width: 110, height: 20 })
268
+ try {
269
+ await press(s)
270
+ await press(s, () => s.mockInput.pressKey("c")) // on a bucket header: nothing to check out
271
+ expect(calls).toEqual([])
272
+
273
+ await press(s, () => s.mockInput.pressArrow("down"))
274
+ await press(s, () => s.mockInput.pressKey("c"))
275
+ expect(s.captureCharFrame()).toContain("Checking out PR #21325…") // the app stays up meanwhile
276
+ await press(s, () => s.mockInput.pressKey("c")) // ignored while busy
277
+ expect(calls).toEqual(["checkout #21325"])
278
+ await press(s, () => finish())
279
+ expect(s.captureCharFrame()).toContain("✓ Checked out PR #21325")
280
+
281
+ await press(s, () => s.mockInput.pressArrow("down"))
282
+ await press(s, () => s.mockInput.pressKey("w"))
283
+ await press(s)
284
+ expect(calls).toEqual(["checkout #21325", "worktree #21324"])
285
+ expect(s.captureCharFrame()).toContain("✗ w needs gh 2.98 or newer")
286
+ } finally { s.renderer.destroy() }
287
+ })