dsh-git-badge 0.7.0 → 0.8.0

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/CHANGELOG.md CHANGED
@@ -10,12 +10,68 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.8.0] - 2026-09-12
14
+
15
+ ### Added
16
+
17
+ - **A hover card on the input chip.** Resting a pointer on the chip now shows what
18
+ the chip has no room for: the file breakdown (`✎3` split into staged / unstaged
19
+ / unmerged / untracked, so *ready to commit* is distinguishable from *not staged
20
+ yet*), the last three commits with their ages, and the stash count. When the
21
+ untracked walk fell back to git's collapsed count the card says `(collapsed)`
22
+ rather than presenting an under-count as exact.
23
+ The card consumes the `detail=1` payload the node half has carried unused since
24
+ 0.6.0, and it is **lazy**: the extra fields cost a `log -3` plus a stash list, so
25
+ they are fetched only once a pointer rests on the chip and then kept fresh over
26
+ the same SSE path. A badge refresh fires on every file edit, and the everyday
27
+ chip still pays for none of it. It uses the shell's own seeded `Tooltip`
28
+ primitive — no new dependency, no seam patch — and if that primitive is ever
29
+ absent the chip renders uncarded rather than not at all. The boot log reports
30
+ which path was taken (`[dsh-git-badge] hover card = on (shell Tooltip
31
+ primitive).`), because the guard that keeps the badge working would otherwise
32
+ make a missing seed look like a missing feature.
33
+ - **A PR / CI token on the input chip.** When the branch has a GitHub pull
34
+ request the chip carries `PR#142` with the rollup state (`✓` passing, `…`
35
+ running, `✗` failing) and a `draft` marker. It reads the PR through the user's
36
+ own `gh` CLI, so the plugin never handles a credential. The rollup is reduced to
37
+ one worst-case state (a single failure outweighs any number of successes), the
38
+ token carries an accessible name naming the state, and the hover card spells out
39
+ the check state, draft and review decision.
40
+ - **The PR token links to the pull request.** When `gh` reports an http(s) URL the
41
+ token is an anchor that opens the PR in a new tab, so this browser tab keeps the
42
+ conversation. It underlines on hover or focus only — the at-rest signal is the
43
+ pointer cursor — and keeps the chip's own colour instead of the browser's link
44
+ blue, so it still reads as one row. Being an anchor, it is also keyboard-reachable
45
+ now, which the text token was not. The protocol is checked on both halves: only
46
+ `http:`/`https:` is carried or rendered, so a payload value can never become a
47
+ `javascript:` href.
48
+ - `?pr=1` on the status route. PR/CI is asked for by the **chip only**, so a
49
+ sidebar that surveys twenty workspaces never spawns twenty `gh` processes. The
50
+ read is TTL-bounded (~90s per repository), refreshed out of band exactly like
51
+ the existing fetch, and notifies subscribers only when the state actually
52
+ changes.
53
+ - **PR/CI degrades to an absent field, never an error.** No `gh`, a logged-out
54
+ `gh`, a non-GitHub remote, an offline network, a rate limit, unparseable output
55
+ and a timed-out call all render the unchanged chip: a caller cannot tell "no PR"
56
+ from "no `gh`", because neither changes what should be displayed.
57
+ - A `runCli` helper the git and `gh` invocations now share, so `gh` inherits the
58
+ same timeout / missing-binary / exit-code semantics (and `GIT_TERMINAL_PROMPT`'s
59
+ analogue, `GH_PROMPT_DISABLED`, keeps an auth prompt from hanging a refresh).
60
+
61
+ ### Changed
62
+
63
+ - The `detail=1` payload has a consumer at last. Its annotation said "no surface
64
+ consumes this yet"; the input chip's hover card is that surface, so the
65
+ branch/last-commits/stash fields are no longer speculative.
66
+ - `config` gains `prStatus` (`"auto"` / `"off"`), `prTtlMs`, `prTimeoutMs` and a
67
+ `prRunner` test seam mirroring `gitRunner`.
68
+
13
69
  ## [0.7.0] - 2026-09-12
14
70
 
15
71
  ### Changed
16
72
 
17
73
  - **Both surfaces draw the same status mark.** The input chip no longer leads with
18
- a 🔴/🟡/🟢 emoji dot: it renders the SAME 12px SVG mark as the sidebar row — a
74
+ a 🔴/🟡/🟢 emoji dot: it renders the SAME SVG mark as the sidebar row — a
19
75
  filled **circle**, or a **tree** for a linked `git worktree` — so the shape says
20
76
  *worktree or not* and the fill says *status*, identically wherever the badge
21
77
  appears. The `⑂` worktree glyph is gone with the emoji: the tree shape already
@@ -136,6 +192,7 @@ npm. 0.6.0 is the first release recorded here.
136
192
  - `detail=1` is annotated as having no consumer yet (the hover card remains
137
193
  pending). Its comment previously claimed `log -1` while the code ran `log -3`.
138
194
 
139
- [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.7.0...HEAD
195
+ [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.8.0...HEAD
196
+ [0.8.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.8.0
140
197
  [0.7.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.7.0
141
198
  [0.6.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.6.0
package/README.md CHANGED
@@ -3,11 +3,13 @@
3
3
  Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
4
4
 
5
5
  - **Input-row chip** — `● main ↑0 ↓2 ✎3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
6
- - **Sidebar row badges** — the same status mark floated to the right of each workspace row (`Project 1 ●`), with a linked worktree's name beside it (`Project 2 hotfix-tree 🌳`). Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
6
+ - **Hover card** — rest a pointer on the chip for what won't fit: the file breakdown (`✎3` split into staged / unstaged / untracked), the last three commits, and the stash. Fetched only on hover, so the everyday badge pays nothing for it. Works on every install.
7
+ - **PR / CI token** — `● main ↑0 ↓2 PR#142 ✗` when the branch has a GitHub pull request, read through your own `gh` CLI (the plugin never handles a credential). The token **links to the PR** — it opens in a new tab, and underlines when you hover or focus it. Invisible when `gh` cannot answer: not installed, not logged in, no GitHub remote, offline or rate-limited all render the unchanged chip, and a token with no URL to link to stays plain text.
8
+ - **Sidebar row badges** — the same status mark floated to the right of each workspace row (`Project 1 ●`), with a linked worktree's name beside it (`Project 2 hotfix-tree 🌳`). Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console. The hover card and PR token are chip-only, so they never need the seam.
7
9
 
8
10
  The leading **mark** is identical on both surfaces: a circle filled with the status colour, or a **tree** when the workspace is a linked `git worktree`. (`●`/`🌳` stand in for it here — it is a drawn SVG so that it can take the theme's status colours.)
9
11
 
10
- Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample. The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
12
+ Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample (plus optionally one `gh` call per ~90s, chip only). The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
11
13
 
12
14
  ## Install
13
15
 
@@ -15,7 +17,7 @@ Event-driven freshness: working trees are watched and updates push over SSE —
15
17
  dsh plugin --profile web add dsh-git-badge
16
18
  ```
17
19
 
18
- Restart the web process, refresh the browser. Requires Node ≥ 20 and `git` on PATH.
20
+ Restart the web process, refresh the browser. Requires Node ≥ 20 and `git` on PATH. `gh`, on PATH and authenticated, is optional — it only enables the PR/CI token.
19
21
 
20
22
  Seam details: [github.com/Kevin-McIsaac/dsh-workspace-git-badge](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) (SEAM.md / PR.md).
21
23
 
package/lib/client.js CHANGED
@@ -10,8 +10,15 @@
10
10
  * floated right, and a worktree's name beside it. Deliberately shows NO
11
11
  * branch: the chip is the surface that names it.
12
12
  * - conversation.input.left (upstream): chip — status mark + branch + a
13
- * worktree's name + in-progress operation token + sync/dirty suffix
14
- * The hover card is intentionally untouched.
13
+ * worktree's name + in-progress operation token + sync/dirty suffix + the
14
+ * PR/CI token, with a HOVER CARD carrying the breakdown the chip has no room
15
+ * for (file detail, recent commits, stash, PR state).
16
+ *
17
+ * The hover card is the INPUT CHIP's, not the sidebar row's, and it needs
18
+ * nothing but the upstream slot: the Tooltip primitive is seeded by the shell
19
+ * itself, so the card works on a PRISTINE install with no seam patch. Its extra
20
+ * fields are fetched only once a pointer rests on the chip, so the everyday
21
+ * badge pays for none of them.
15
22
  */
16
23
  window.__ModuleLoader__.load({
17
24
  id: "dsh-git-badge",
@@ -22,6 +29,24 @@ window.__ModuleLoader__.load({
22
29
  let react_jsx_runtime = require("react/jsx-runtime");
23
30
  let react = require("react");
24
31
 
32
+ /**
33
+ * Shell-seeded UI primitives. `Tooltip` is handed to EVERY client bundle
34
+ * by the web shell itself — it needs no `dsh.client.inject` entry and no
35
+ * npm dependency (dsh-client-ui-open-in-app requires it the same way).
36
+ *
37
+ * Guarded, because the seed table belongs to the shell and not to this
38
+ * plugin: on a shell that does not provide it the chip must still render,
39
+ * just without the hover card. A missing card is a smaller failure than a
40
+ * missing badge.
41
+ */
42
+ let primitives = null;
43
+ try {
44
+ primitives = require("@deepseek-ai/dsh-client-ui-primitives");
45
+ } catch {
46
+ primitives = null;
47
+ }
48
+ const Tooltip = primitives === null ? void 0 : primitives.Tooltip;
49
+
25
50
  /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
26
51
  const GIT_CACHE = new Map();
27
52
  /** In-flight fetches per cacheKey: bursts collapse into one request. */
@@ -35,11 +60,21 @@ window.__ModuleLoader__.load({
35
60
  * one. The chip targets its conversation (`session`); a sidebar row
36
61
  * targets the workspace id the seam hands it. The query string doubles as
37
62
  * the cache key.
63
+ *
64
+ * The two optional additions are the INPUT CHIP's alone, and each is an
65
+ * explicit request rather than something every badge pays for:
66
+ * - `detail=1` — recent commits and stash for the hover card;
67
+ * - `pr=1` — the branch's GitHub PR/CI state.
68
+ * A sidebar row asks for neither, so surveying N workspaces never costs N
69
+ * forge calls (see the node half's PR region).
38
70
  */
39
- function targetQuery(target) {
71
+ function targetQuery(target, options) {
40
72
  if (target === void 0 || target.id === void 0) return void 0;
41
73
  const param = target.kind === "workspace" ? "workspace" : "session";
42
- return param + "=" + encodeURIComponent(target.id);
74
+ let query = param + "=" + encodeURIComponent(target.id);
75
+ if (options?.pr === true) query += "&pr=1";
76
+ if (options?.detail === true) query += "&detail=1";
77
+ return query;
43
78
  }
44
79
 
45
80
  //#region SSE change feed (one EventSource per page, ref-counted)
@@ -90,16 +125,24 @@ window.__ModuleLoader__.load({
90
125
  * node half's watcher reports a change for this target (SSE), plus a slow
91
126
  * safety-net poll in case the stream dies silently. Returns undefined
92
127
  * while loading and for non-git workspaces.
128
+ *
129
+ * `options.detail` / `options.pr` ask the node half for the chip's extras
130
+ * (see targetQuery). `options.enabled: false` holds the fetch back
131
+ * entirely — that is what makes the hover card's extra fields LAZY: the
132
+ * chip renders without them and only starts paying for `log -3` plus a
133
+ * stash list once a pointer actually rests on it. The hook is still called
134
+ * unconditionally (hooks may not be conditional); the GATE is inside.
93
135
  */
94
- function useGitStatus(target) {
95
- const cacheKey = targetQuery(target);
136
+ function useGitStatus(target, options) {
137
+ const enabled = options?.enabled !== false;
138
+ const cacheKey = targetQuery(target, options);
96
139
  const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
97
140
  // state carries the key it belongs to: switching conversations changes
98
141
  // the key, and showing the previous session's badge until the new fetch
99
142
  // lands would be wrong
100
143
  const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
101
144
  react.useEffect(() => {
102
- if (cacheKey === void 0) return;
145
+ if (cacheKey === void 0 || !enabled) return;
103
146
  let alive = true;
104
147
  // The workspace this target resolved to, taken from the response (the
105
148
  // server echoes it). THIS is what events are matched against: an event
@@ -149,7 +192,7 @@ window.__ModuleLoader__.load({
149
192
  unsubscribe();
150
193
  clearInterval(fallback);
151
194
  };
152
- }, [cacheKey]);
195
+ }, [cacheKey, enabled]);
153
196
  return state.key === cacheKey ? state.data : void 0;
154
197
  }
155
198
 
@@ -222,6 +265,57 @@ window.__ModuleLoader__.load({
222
265
  return parts.length > 0 ? " " + parts.join(" ") : "";
223
266
  }
224
267
 
268
+ /**
269
+ * PR / CI token — INPUT CHIP ONLY, and absent entirely when the node half
270
+ * has nothing to say (no `gh`, no GitHub remote, no PR for the branch), so
271
+ * a machine without `gh` sees precisely the chip it saw before.
272
+ *
273
+ * The state glyph is not the only channel: the token carries an accessible
274
+ * name spelling the state out, the same rule the status mark follows.
275
+ */
276
+ const PR_STATE_GLYPH = { passing: "\u2713", pending: "\u2026", failing: "\u2717", none: "" };
277
+ const PR_STATE_LABEL = { passing: "checks passing", pending: "checks running", failing: "checks failing", none: "no checks" };
278
+
279
+ /** ` PR#142 ✗` (plus ` draft`), or "" when there is no PR to report. */
280
+ function formatPrToken(info) {
281
+ const pr = info.pr;
282
+ if (pr === void 0 || pr === null || pr.number === void 0) return "";
283
+ const state = PR_STATE_GLYPH[pr.state] === void 0 ? "none" : pr.state;
284
+ const glyph = PR_STATE_GLYPH[state];
285
+ return " PR#" + pr.number + (glyph === "" ? "" : " " + glyph) + (pr.draft === true ? " draft" : "");
286
+ }
287
+
288
+ /** The token's accessible name: "pull request 142, checks failing, draft". */
289
+ function prTokenLabel(info) {
290
+ const pr = info.pr;
291
+ if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
292
+ const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
293
+ return "pull request " + pr.number + ", " + state + (pr.draft === true ? ", draft" : "");
294
+ }
295
+
296
+ /**
297
+ * The PR's web URL, or undefined — the token is a LINK only when the node
298
+ * half vouched for a URL, and is plain text otherwise (no `gh`, no GitHub
299
+ * remote, an unauthenticated `gh`: the token still appears, it just does not
300
+ * pretend to be clickable, so there is no dead link to discover).
301
+ *
302
+ * The protocol is re-checked HERE even though the node half already dropped
303
+ * a non-http(s) value: an `href` is the one place a payload string becomes
304
+ * executable, so the element that creates it verifies its own input rather
305
+ * than trusting an upstream guard to stay in place. The shell's own markdown
306
+ * renderer guards its links the same way.
307
+ */
308
+ function prLinkUrl(info) {
309
+ const pr = info.pr;
310
+ if (pr === void 0 || pr === null || typeof pr.url !== "string") return void 0;
311
+ try {
312
+ const { protocol } = new URL(pr.url);
313
+ return protocol === "http:" || protocol === "https:" ? pr.url : void 0;
314
+ } catch {
315
+ return void 0;
316
+ }
317
+ }
318
+
225
319
  /**
226
320
  * In-progress operation token — INPUT CHIP ONLY (the row shows the status
227
321
  * tree alone). A paused rebase or cherry-pick whose conflicts are all
@@ -293,7 +387,7 @@ window.__ModuleLoader__.load({
293
387
  };
294
388
 
295
389
  /**
296
- * Status mark, SHARED BY BOTH SURFACES: the same 12px shape in the same fill
390
+ * Status mark, SHARED BY BOTH SURFACES: the same shape in the same fill
297
391
  * wherever it appears, so the sidebar row and the input chip read
298
392
  * identically. The FILL is the status colour; the SHAPE carries
299
393
  * worktree-ness — a circle for a main checkout, a tree for a linked
@@ -390,6 +484,102 @@ window.__ModuleLoader__.load({
390
484
  return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
391
485
  }
392
486
 
487
+ /**
488
+ * The chip's `✎n` is one number for four different things; the card splits
489
+ * it, so "ready to commit" is distinguishable from "not staged yet". The
490
+ * collapsed caveat is the node half's own honesty flag: when the untracked
491
+ * walk blew its budget the count is a DIRECTORY count, and saying so beats
492
+ * presenting an under-count as exact.
493
+ */
494
+ function formatFileBreakdown(info) {
495
+ const parts = [];
496
+ if ((info.stagedFiles || 0) > 0) parts.push(info.stagedFiles + " staged");
497
+ if ((info.unstagedFiles || 0) > 0) parts.push(info.unstagedFiles + " unstaged");
498
+ if ((info.unmergedFiles || 0) > 0) parts.push(info.unmergedFiles + " unmerged");
499
+ const untracked = info.untrackedFiles || 0;
500
+ if (untracked > 0) parts.push(untracked + " untracked" + (info.untrackedMode === "collapsed" ? " (collapsed)" : ""));
501
+ return parts.length === 0 ? "clean" : parts.join(" \u00B7 ");
502
+ }
503
+
504
+ /** The PR's full state as text, for the card: "#142 · checks failing · draft · review approved". */
505
+ function formatPrDetail(pr) {
506
+ if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
507
+ const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
508
+ const bits = ["#" + pr.number, state];
509
+ if (pr.draft === true) bits.push("draft");
510
+ if (pr.review !== void 0) bits.push(String(pr.review).toLowerCase().replace(/_/g, " "));
511
+ if (pr.open === false) bits.push("not open");
512
+ return bits.join(" \u00B7 ");
513
+ }
514
+
515
+ const CARD_CONTAINER = {
516
+ display: "flex",
517
+ flexDirection: "column",
518
+ gap: "4px",
519
+ fontSize: "12px",
520
+ lineHeight: "18px",
521
+ maxWidth: "340px"
522
+ };
523
+ const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
524
+ const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
525
+ const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
526
+
527
+ /**
528
+ * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
529
+ * over fields the status response already carries, plus the `detail=1`
530
+ * additions (recent commits, stash) when they have arrived. Everything is
531
+ * spelled out in text, so no fact here depends on a colour or a glyph.
532
+ */
533
+ function HoverCard({ info, detail }) {
534
+ // the detail payload is a superset of the plain one, so it wins when it
535
+ // has landed; until then the card still shows the base facts
536
+ const data = detail === void 0 ? info : detail;
537
+ const rows = [];
538
+ const add = (label, value) => {
539
+ if (value === void 0 || value === null || value === "") return;
540
+ rows.push(
541
+ react_jsx_runtime.jsxs("div", {
542
+ style: CARD_ROW,
543
+ children: [
544
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
545
+ react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
546
+ ]
547
+ }, label)
548
+ );
549
+ };
550
+ add("branch", info.branch);
551
+ add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
552
+ if (info.ahead !== void 0 || info.behind !== void 0) {
553
+ add("sync", "\u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0));
554
+ }
555
+ add("files", formatFileBreakdown(info));
556
+ add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
557
+ add("pull request", formatPrDetail(data.pr));
558
+ if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
559
+ rows.push(
560
+ react_jsx_runtime.jsxs("div", {
561
+ style: CARD_ROW,
562
+ children: [
563
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: "commits" }),
564
+ react_jsx_runtime.jsx("span", {
565
+ style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
566
+ children: data.lastCommits.map((commit) =>
567
+ react_jsx_runtime.jsxs("span", {
568
+ children: [
569
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: commit.hash }),
570
+ " " + commit.subject + (commit.when === "" ? "" : " \u00B7 " + commit.when)
571
+ ]
572
+ }, commit.hash)
573
+ )
574
+ })
575
+ ]
576
+ }, "commits")
577
+ );
578
+ }
579
+ add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
580
+ return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
581
+ }
582
+
393
583
  //#region composer chip (upstream additive surface: conversation.input.left)
394
584
  /**
395
585
  * Chip line in the input row: git state of the workspace the CURRENT
@@ -399,17 +589,32 @@ window.__ModuleLoader__.load({
399
589
  * (conversation.input.left is an upstream additive slot), and stays useful
400
590
  * next to the sidebar rows because it is context-anchored ("where am I")
401
591
  * rather than surveying.
592
+ *
593
+ * This is the ONLY surface that asks the node half for the extras (`pr=1`
594
+ * always, `detail=1` on hover), because it is the only one that renders
595
+ * them — see targetQuery.
402
596
  */
403
597
  function ComposerGitChip({ sessionId }) {
404
- // no detail=1: nothing renders lastCommits / stashCount yet, and asking
405
- // for them would add a log -3 plus a stash list to every refresh
406
- const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId });
598
+ const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
599
+ // the PR/CI token is always on the chip, so its fetch is not gated
600
+ const info = useGitStatus(target, { pr: true });
601
+ // The card's extras cost a `log -3` plus a stash list, so they are
602
+ // fetched only once a pointer actually RESTS on the chip, then kept
603
+ // fresh by the same SSE path. Eagerly asking would add both invocations
604
+ // to every refresh — and a refresh fires on every file edit.
605
+ const [hovered, setHovered] = react.useState(false);
606
+ // the PR token's own hover/focus state: it underlines on hover (and on
607
+ // focus) rather than at rest, and that underline is applied inline below
608
+ const [linkHover, setLinkHover] = react.useState(false);
609
+ const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
407
610
  if (info === void 0 || info.git !== true) return null;
408
611
  // the mark is an element now rather than a leading glyph in the string, so
409
612
  // the SAME StatusMark the sidebar row draws carries the status here too;
410
613
  // the container's 4px gap supplies the space the emoji's own did
411
614
  const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
412
- return react_jsx_runtime.jsx("span", {
615
+ const prToken = formatPrToken(info);
616
+ const prUrl = prLinkUrl(info);
617
+ const chip = react_jsx_runtime.jsxs("span", {
413
618
  style: {
414
619
  display: "inline-flex",
415
620
  alignItems: "center",
@@ -421,11 +626,63 @@ window.__ModuleLoader__.load({
421
626
  whiteSpace: "nowrap",
422
627
  cursor: "default"
423
628
  },
629
+ // the card is fetched for a pointer that RESTS here, not one that
630
+ // merely crosses the chip
631
+ onPointerEnter: () => setHovered(true),
424
632
  children: [
425
633
  react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
426
- react_jsx_runtime.jsx("span", { key: "text", children: text })
634
+ react_jsx_runtime.jsx("span", { key: "text", children: text }),
635
+ prToken === ""
636
+ ? null
637
+ : prUrl === void 0
638
+ ? react_jsx_runtime.jsx("span", {
639
+ key: "pr",
640
+ // the glyph is not the only channel: the token says what the
641
+ // CI state IS, for anyone who cannot see it
642
+ "aria-label": prTokenLabel(info),
643
+ children: prToken
644
+ })
645
+ : react_jsx_runtime.jsx("a", {
646
+ key: "pr",
647
+ href: prUrl,
648
+ // a new tab, because navigating THIS one away from the app
649
+ // would lose the conversation; noopener/noreferrer keep the
650
+ // opened tab from getting a handle on it
651
+ target: "_blank",
652
+ rel: "noopener noreferrer",
653
+ "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
654
+ // the chip's own colour rather than the browser's link blue /
655
+ // visited purple, so the token still reads as one row. The
656
+ // affordance is the pointer cursor plus an underline on hover
657
+ // or focus, set HERE rather than left to the host stylesheet:
658
+ // a shell that resets anchors would otherwise drop it silently.
659
+ style: {
660
+ color: "inherit",
661
+ textDecoration: linkHover ? "underline" : "none",
662
+ cursor: "pointer"
663
+ },
664
+ // focus joins hover — the token is keyboard-reachable now, and a
665
+ // keyboard user needs the same "this is a link" signal
666
+ onPointerEnter: () => setLinkHover(true),
667
+ onPointerLeave: () => setLinkHover(false),
668
+ onFocus: () => setLinkHover(true),
669
+ onBlur: () => setLinkHover(false),
670
+ children: prToken
671
+ })
427
672
  ]
428
673
  });
674
+ // No Tooltip primitive (a shell that does not seed it): render the chip
675
+ // itself rather than losing the badge. The card is an enhancement, the
676
+ // badge is the feature.
677
+ if (Tooltip === void 0) return chip;
678
+ return react_jsx_runtime.jsx(Tooltip, {
679
+ side: "top",
680
+ maxWidth: 360,
681
+ // a function label keeps the card's element tree out of every render
682
+ // until the tooltip actually opens
683
+ label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
684
+ children: chip
685
+ });
429
686
  }
430
687
  //#endregion
431
688
 
@@ -474,10 +731,25 @@ window.__ModuleLoader__.load({
474
731
  }, ComposerGitChip));
475
732
  const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
476
733
  console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)." : "awaiting the sidebar.workspaces.row seam — the line above reports it if it appears."));
734
+ // The hover card depends on a primitive the SHELL seeds, not on anything
735
+ // this plugin declares. Report the outcome rather than letting a missing
736
+ // seed look like a missing feature: the try/catch above deliberately keeps
737
+ // the badge working, which would otherwise make this failure silent.
738
+ console.info(
739
+ "[dsh-git-badge] hover card = " +
740
+ (Tooltip === void 0
741
+ ? "off (this shell seeds no Tooltip primitive) — the chip renders uncarded."
742
+ : "on (shell Tooltip primitive).")
743
+ );
477
744
  }
478
745
 
479
746
  exports.apply = apply;
480
747
  exports.inject = inject;
748
+ // ---------- test-only exports ----------
749
+ // Additive; the host reads apply/inject and ignores the rest. The suite
750
+ // drives these to assert the REQUEST contract — which surface asks for the
751
+ // expensive extras — without a browser, a fetch or a network.
752
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail };
481
753
  return module.exports;
482
754
  }
483
755
  });
package/lib/index.js CHANGED
@@ -44,9 +44,14 @@
44
44
  * on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
45
45
  * shared timeout and the TTL bound keep an offline or slow remote harmless;
46
46
  * on failure the stale-ref answer simply stands.
47
- * - `detail=1` adds one `log -3` plus a stash count. NOTE: no surface
48
- * consumes this yet (see the annotation on gitStatus) — it is kept for
49
- * the planned hover card, and never paid for by row refresh.
47
+ * - `detail=1` adds one `log -3` plus a stash count, and `pr=1` adds the
48
+ * branch's GitHub PR/CI state read through the user's own `gh` CLI. Both
49
+ * serve the input chip only — the hover card and the PR token — so a
50
+ * sidebar row refresh pays for neither. The PR read is TTL-bounded and
51
+ * out of band, like the fetch; it degrades to an ABSENT field rather than
52
+ * an error whenever `gh` cannot answer (see the PR region).
53
+ * - `pr=1` requires `gh` on PATH and authenticated for a token to appear.
54
+ * The plugin never handles the credential: `gh` owns it.
50
55
  *
51
56
  * GET /api/git-badge/events (text/event-stream)
52
57
  * - pushes `{ path }` dirty notifications the moment a workspace's git
@@ -100,23 +105,48 @@ const config = {
100
105
  * collapsed-fallback branch deterministically — a real sub-millisecond budget
101
106
  * would just race git's own startup.
102
107
  */
103
- gitRunner: null
108
+ gitRunner: null,
109
+ /**
110
+ * PR / CI status via the `gh` CLI: "auto" reads it where `gh` can answer and
111
+ * silently shows nothing where it cannot; "off" never invokes `gh` at all.
112
+ * Only the input chip asks for it, so a sidebar-only view spawns no forge
113
+ * process — see the PR region.
114
+ */
115
+ prStatus: "auto",
116
+ /** minimum interval between forge refreshes per repository */
117
+ prTtlMs: 90000,
118
+ /**
119
+ * Forge call budget. Unlike git this is a network round trip to GitHub, so
120
+ * it gets a more generous budget than the status sample — but it is bounded
121
+ * all the same, because nothing here may hang the route.
122
+ */
123
+ prTimeoutMs: 6000,
124
+ /**
125
+ * Invoker for the `gh` CLI; null means the real runCli. The suite substitutes
126
+ * one so every degradation path (no gh, logged out, no PR, garbage output,
127
+ * timeout) is covered with no gh, no network and no forge.
128
+ */
129
+ prRunner: null
104
130
  };
105
131
 
106
132
  /**
107
- * Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
108
- * plus exactly one of: timeout (execFile timeout kill), missing (no git
109
- * binary), or exitCode (git ran and rejected — e.g. "not a repository").
110
- * The exitCode/timeout distinction separates a definitive answer (non-repo)
111
- * from a transient one (degraded).
133
+ * Run an external command in a directory. Resolves { stdout } on success; on
134
+ * failure { stdout: null } plus exactly one of: timeout (execFile timeout kill),
135
+ * missing (no such binary), or exitCode (the command ran and rejected — e.g.
136
+ * "not a repository", or `gh` reporting no pull request).
137
+ *
138
+ * The exitCode/timeout/missing trichotomy is what lets a CALLER tell a
139
+ * definitive answer from a transient one: a missing `gh` and a `gh` that says
140
+ * "no PR" are different facts, and only the caller knows which of them it can
141
+ * act on. Shared by git and `gh` so neither grows its own timeout semantics.
112
142
  */
113
- function runGit(dir, args, opts) {
114
- const { env, timeout = config.gitTimeoutMs } = opts ?? {};
143
+ function runCli(cmd, args, opts) {
144
+ const { cwd, env, timeout = config.gitTimeoutMs } = opts ?? {};
115
145
  // env is a full replacement for execFile; overlay onto process.env so
116
146
  // PATH/HOME (credential helpers, global config) survive
117
147
  const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
118
148
  return new Promise((resolve) => {
119
- execFile("git", args, { cwd: dir, timeout, env: fullEnv }, (error, stdout) => {
149
+ execFile(cmd, args, { cwd, timeout, env: fullEnv }, (error, stdout) => {
120
150
  if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
121
151
  else if (error.killed) resolve({ stdout: null, timeout: true });
122
152
  else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
@@ -125,6 +155,15 @@ function runGit(dir, args, opts) {
125
155
  });
126
156
  }
127
157
 
158
+ /**
159
+ * Run git in dir. A thin specialization of runCli: the contract above is
160
+ * unchanged, and `config.gitRunner` still substitutes for the whole call so the
161
+ * suite can force the timeout branch.
162
+ */
163
+ function runGit(dir, args, opts) {
164
+ return runCli("git", args, { ...opts, cwd: dir });
165
+ }
166
+
128
167
  /** Marker response for a git invocation that failed or timed out (transient). */
129
168
  const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
130
169
 
@@ -178,6 +217,167 @@ async function maybeFetch(toplevel) {
178
217
  }
179
218
  //#endregion
180
219
 
220
+ //#region PR / CI status (gh)
221
+ /**
222
+ * GitHub pull-request and check state for the current branch, read through the
223
+ * user's own `gh` CLI.
224
+ *
225
+ * Why the CLI and not the REST API: `gh` already owns the user's credentials in
226
+ * its own config, so this plugin never reads, stores, caches or forwards a
227
+ * token. There is no secret here to leak and no OAuth flow to maintain — and a
228
+ * machine that has never authenticated `gh` simply gets no PR token.
229
+ *
230
+ * Degradation is a MISSING FIELD, never an error. A machine without `gh`, a
231
+ * logged-out `gh`, a remote that is not GitHub, an offline network, a forge rate
232
+ * limit, unparseable output and a timed-out call all produce exactly the same
233
+ * response: no `pr` key at all. That is the honest answer, because in every one
234
+ * of those cases the badge has nothing to say — and a caller that could tell
235
+ * "no PR" from "no gh" would still not know what to draw.
236
+ *
237
+ * Like the fetch above this is TTL-bounded and NEVER awaited by the route: the
238
+ * cached value is served immediately and a refresh that CHANGES it notifies
239
+ * subscribers, so the request that lapses the window carries the previous answer
240
+ * and the follow-up carries the new one. A forge round trip must never become
241
+ * badge latency.
242
+ */
243
+ /** toplevel -> { lastAttemptAt, inFlight, value }; `value` undefined = nothing to say. */
244
+ const prState = new Map();
245
+
246
+ /** Test seam: the CLI invoker `gh` calls go through, so no test needs `gh`. */
247
+ function runPrCli(cmd, args, opts) {
248
+ return (config.prRunner ?? runCli)(cmd, args, opts);
249
+ }
250
+
251
+ /**
252
+ * Does `origin` point at github.com? A cheap LOCAL call, so a non-GitHub repo
253
+ * never pays for a `gh` process. Deliberately scoped to origin: that is the
254
+ * remote `gh` itself resolves the repository from, so agreeing with it here is
255
+ * what keeps the two from disagreeing later.
256
+ */
257
+ async function originIsGitHub(toplevel) {
258
+ const out = await runGit(toplevel, ["remote", "get-url", "origin"]);
259
+ if (out.stdout === null) return false;
260
+ return /(^|[/@.])github\.com[:/]/i.test(out.stdout.trim());
261
+ }
262
+
263
+ /**
264
+ * Reduce a `statusCheckRollup` to ONE worst-case state, so the chip needs a
265
+ * single colour rather than a list.
266
+ *
267
+ * Two entry shapes share the array: a CheckRun carries `status` + `conclusion`,
268
+ * a StatusContext carries `state` alone. A single failure outweighs any number
269
+ * of successes and pending checks — "mostly passing" is not a thing a red/green
270
+ * token can express — so failure returns immediately and pending only wins over
271
+ * an otherwise all-successful set.
272
+ */
273
+ function summarizeChecks(checks) {
274
+ let sawAny = false;
275
+ let sawPending = false;
276
+ for (const check of checks) {
277
+ const state = String(check?.state ?? "").toUpperCase();
278
+ const status = String(check?.status ?? "").toUpperCase();
279
+ const conclusion = String(check?.conclusion ?? "").toUpperCase();
280
+ sawAny = true;
281
+ const failed =
282
+ ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(state) ||
283
+ ["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion);
284
+ if (failed) return "failing";
285
+ // unfinished: a CheckRun that has not completed, or a StatusContext PENDING
286
+ if (state === "PENDING" || (status !== "" && status !== "COMPLETED")) sawPending = true;
287
+ }
288
+ if (!sawAny) return "none";
289
+ return sawPending ? "pending" : "passing";
290
+ }
291
+
292
+ /** The compact PR shape the chip renders, or undefined when there is nothing to say. */
293
+ function summarizePr(json) {
294
+ if (json === null || typeof json !== "object") return void 0;
295
+ const number = Number.isFinite(json?.number) ? json.number : void 0;
296
+ if (number === void 0) return void 0;
297
+ const pr = {
298
+ number,
299
+ state: summarizeChecks(Array.isArray(json.statusCheckRollup) ? json.statusCheckRollup : []),
300
+ // draft and review state are separate from CI: a draft with green checks
301
+ // is not ready, and a blocked review is not a failing build
302
+ draft: json.isDraft === true,
303
+ open: String(json.state ?? "OPEN").toUpperCase() === "OPEN"
304
+ };
305
+ // omitted, not present-and-undefined: absence is the contract for "nothing to
306
+ // say" throughout this half, so a consumer tests the key, never the value
307
+ if (typeof json.reviewDecision === "string" && json.reviewDecision !== "") pr.review = json.reviewDecision;
308
+ // The chip links the token to the PR, so `url` is the one field that becomes an
309
+ // `href`: only http(s) is emitted, and anything else is omitted like every
310
+ // other "nothing to say" field, so a payload value cannot reach an anchor as a
311
+ // `javascript:` URL. The client re-checks before it builds the href — this half
312
+ // is the authority on what it vouches for, not the only guard.
313
+ if (typeof json.url === "string" && json.url !== "") {
314
+ try {
315
+ const { protocol } = new URL(json.url);
316
+ if (protocol === "http:" || protocol === "https:") pr.url = json.url;
317
+ } catch {
318
+ // not a URL at all — omitted, exactly like an absent one
319
+ }
320
+ }
321
+ return pr;
322
+ }
323
+
324
+ /**
325
+ * Read PR state for `branch`, or undefined. NEVER throws and never rejects: a
326
+ * forge is an optional luxury, so every failure path is "nothing to say".
327
+ */
328
+ async function readPrStatus(toplevel, branch) {
329
+ // a detached HEAD is not a branch `gh` can resolve a PR for
330
+ if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
331
+ if (!(await originIsGitHub(toplevel))) return void 0;
332
+ const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url"], {
333
+ cwd: toplevel,
334
+ timeout: config.prTimeoutMs,
335
+ // GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
336
+ // analogue of the git fetch's GIT_TERMINAL_PROMPT=0). The pager vars keep
337
+ // gh from waiting on a pager that will never answer, and NO_COLOR keeps
338
+ // escape codes out of the JSON.
339
+ env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
340
+ });
341
+ if (out.stdout === null) return void 0;
342
+ let json;
343
+ try {
344
+ json = JSON.parse(out.stdout);
345
+ } catch {
346
+ return void 0;
347
+ }
348
+ return summarizePr(json);
349
+ }
350
+
351
+ /**
352
+ * PR state for toplevel: the value to serve NOW (possibly undefined, possibly
353
+ * stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
354
+ * called only when the refreshed value CHANGES, so an unchanged answer costs one
355
+ * forge call per TTL and zero SSE traffic.
356
+ */
357
+ function prStatusFor(toplevel, branch, notify) {
358
+ if (config.prStatus === "off") return void 0;
359
+ const now = Date.now();
360
+ const state = prState.get(toplevel);
361
+ if (state !== void 0) {
362
+ // Fresh, or a refresh is already running: serve what we have either way.
363
+ if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
364
+ }
365
+ const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
366
+ const inFlight = (async () => {
367
+ const value = await readPrStatus(toplevel, branch).catch(() => void 0);
368
+ const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
369
+ record.lastAttemptAt = Date.now();
370
+ record.inFlight = null;
371
+ record.value = value;
372
+ if (changed) notify(toplevel);
373
+ return value;
374
+ })();
375
+ record.inFlight = inFlight;
376
+ prState.set(toplevel, record);
377
+ return record.value;
378
+ }
379
+ //#endregion
380
+
181
381
  /**
182
382
  * Parse `git status --porcelain=v2 --branch` output.
183
383
  * Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
@@ -259,11 +459,12 @@ function operationMarker(gitDir) {
259
459
  * worktree reports that worktree (the toplevel walk starts above it), which is
260
460
  * the same whole-repository rule as above.
261
461
  *
262
- * `wantDetail` has no consumer yet: both surfaces fetch without `detail=1`.
263
- * The branch is retained for the planned hover card — nothing renders
264
- * lastCommits / stashCount today.
462
+ * `wantDetail` serves the input chip's HOVER CARD (recent commits + stash) and
463
+ * `wantPr` its PR/CI token. They are asked for separately and by the chip only,
464
+ * so neither is paid for by a sidebar row refresh: a row needs status and
465
+ * identity, nothing more.
265
466
  */
266
- async function gitStatus(dir, wantDetail) {
467
+ async function gitStatus(dir, wantDetail, wantPr) {
267
468
  // one invocation answers both questions: the repository root (so a
268
469
  // subdirectory workspace reports the whole repo) and the per-worktree git
269
470
  // dir (where the operation markers above live)
@@ -360,6 +561,15 @@ async function gitStatus(dir, wantDetail) {
360
561
  if (count > 0) info.stashCount = count;
361
562
  }
362
563
  }
564
+ // PR / CI is the INPUT CHIP's business, so it is asked for explicitly
565
+ // (`?pr=1`) rather than paid for by every sidebar row: a row that surveys
566
+ // twenty workspaces must not spawn twenty forge processes. Served from the
567
+ // TTL cache and refreshed out of band, exactly like the fetch above — the
568
+ // forge round trip is never route latency.
569
+ if (wantPr) {
570
+ const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
571
+ if (pr !== void 0) info.pr = pr;
572
+ }
363
573
  return info;
364
574
  }
365
575
 
@@ -675,7 +885,8 @@ function apply(ctx) {
675
885
  return;
676
886
  }
677
887
  const detail = url.searchParams.get("detail") === "1";
678
- const info = await gitStatus(target.path, detail);
888
+ const pr = url.searchParams.get("pr") === "1";
889
+ const info = await gitStatus(target.path, detail, pr);
679
890
  // Echo the resolved workspace id. SSE events identify the workspace,
680
891
  // not the session, so a session-targeted client has no other way to
681
892
  // tell whether an event belongs to it — without this the input chip
@@ -757,9 +968,15 @@ export {
757
968
  operationMarker,
758
969
  outerGitDir,
759
970
  parseStatusV2,
971
+ runCli,
760
972
  runGit,
761
973
  gitStatus,
762
974
  resolveWorkspace,
975
+ readPrStatus,
976
+ summarizePr,
977
+ summarizeChecks,
978
+ prStatusFor,
979
+ prState,
763
980
  watchWorkspace,
764
981
  unwatchWorkspace,
765
982
  syncWatchers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",