@kurokeita/add-skill 2.4.0 → 2.5.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.
@@ -0,0 +1,411 @@
1
+ import type { EngineInterface, Register } from "claude-code"
2
+ import { atom, read, update } from "claude-code"
3
+
4
+ import type { CheckState, Pr, Step } from "../types"
5
+ import {
6
+ parseSummary,
7
+ platformOf,
8
+ prNumbersIn,
9
+ repoNameOf,
10
+ SUMMARY_SYSTEM,
11
+ transcriptOf,
12
+ } from "./discover"
13
+ import { fetchAzure, fetchGitHub, git, type Run } from "./fetch"
14
+
15
+ const PANE = "work-items"
16
+ const branches = atom({ plugin: "work-items", key: "branches" } as const, [])
17
+ const work = atom({ plugin: "work-items", key: "work" } as const, null)
18
+ const isFetching = atom(
19
+ { plugin: "work-items", key: "isFetching" } as const,
20
+ false,
21
+ )
22
+ const summary = atom({ plugin: "work-items", key: "summary" } as const, null)
23
+ const isSummarizing = atom(
24
+ { plugin: "work-items", key: "isSummarizing" } as const,
25
+ false,
26
+ )
27
+ const collapsed = atom({ plugin: "work-items", key: "collapsed" } as const, [])
28
+
29
+ let isFetchRunning = false
30
+ let isAutoOpenWanted = false
31
+ let isAutoOpened = false
32
+ // Which conversation this process shows, and whether a prompt was sent in it here.
33
+ let watchedId = ""
34
+ let hasPromptedHere = false
35
+ let sessionStartedAt = Date.now()
36
+
37
+ const runner =
38
+ ($: EngineInterface): Run =>
39
+ (argv) =>
40
+ $.process
41
+ .run(argv, { timeoutMs: 60_000 })
42
+ .catch((error) => ({ exitCode: 1, stdout: "", stderr: String(error) }))
43
+
44
+ const isPaneOpen = async ($: EngineInterface) =>
45
+ (await $.ui.panes()).some((pane) => pane.id === PANE)
46
+
47
+ // The branches this session sat on at the end of a turn, newest first.
48
+ async function noteBranch($: EngineInterface): Promise<string> {
49
+ const branch = await git(runner($), ["branch", "--show-current"])
50
+ if (branch)
51
+ await update($, branches, (list) =>
52
+ [branch, ...list.filter((b) => b !== branch)].slice(0, 10),
53
+ )
54
+ return branch
55
+ }
56
+
57
+ // The current branch, plus the branches this session committed to. A branch the
58
+ // session only started on or passed through has no commits since it began.
59
+ async function trackedBranches(
60
+ $: EngineInterface,
61
+ current: string,
62
+ ): Promise<string[]> {
63
+ const since = `--since=${new Date(sessionStartedAt).toISOString()}`
64
+ const tracked = current ? [current] : []
65
+ for (const branch of await read($, branches)) {
66
+ if (branch === current) continue
67
+ if (await git(runner($), ["log", "-1", "--format=%H", since, branch]))
68
+ tracked.push(branch)
69
+ }
70
+ return tracked
71
+ }
72
+
73
+ async function refreshWork($: EngineInterface) {
74
+ if (isFetchRunning) return
75
+ isFetchRunning = true
76
+ await update($, isFetching, () => true)
77
+ try {
78
+ const remote = await git(runner($), ["remote", "get-url", "origin"])
79
+ const platform = platformOf(remote)
80
+ if (!platform) {
81
+ await update($, work, () => ({
82
+ prs: [],
83
+ items: [],
84
+ fetchedAt: Date.now(),
85
+ error: "origin is neither GitHub nor Azure DevOps.",
86
+ }))
87
+ return
88
+ }
89
+ const current = await noteBranch($)
90
+ const prNumbers = prNumbersIn(
91
+ await $.session.messages(),
92
+ platform,
93
+ repoNameOf(remote),
94
+ )
95
+ const fetch = platform === "github" ? fetchGitHub : fetchAzure
96
+ const result = await fetch(
97
+ runner($),
98
+ await trackedBranches($, current),
99
+ prNumbers,
100
+ )
101
+ await update($, work, () => result)
102
+ } finally {
103
+ isFetchRunning = false
104
+ await update($, isFetching, () => false)
105
+ }
106
+ }
107
+
108
+ async function summarize($: EngineInterface) {
109
+ if (await read($, isSummarizing)) return
110
+ await update($, isSummarizing, () => true)
111
+ try {
112
+ const prompt = transcriptOf(await $.session.messages())
113
+ // The API refuses an empty user message with a 400.
114
+ if (!prompt.trim()) {
115
+ await update($, summary, () => ({
116
+ error: "Nothing to summarize yet. Send a prompt first.",
117
+ at: Date.now(),
118
+ }))
119
+ return
120
+ }
121
+ const reply = await $.model.complete({
122
+ model: "haiku",
123
+ system: SUMMARY_SYSTEM,
124
+ prompt,
125
+ maxTokens: 1500,
126
+ })
127
+ const parsed = reply.isAnswered ? parseSummary(reply.text) : null
128
+ const error = reply.isAnswered
129
+ ? "The summary came back unreadable."
130
+ : reply.reason === "api-error"
131
+ ? `Summary failed: api-error ${reply.status ?? ""} ${reply.error}`
132
+ : `Summary failed: ${reply.reason}`
133
+ await update($, summary, () =>
134
+ parsed ? { ...parsed, at: Date.now() } : { error, at: Date.now() },
135
+ )
136
+ } finally {
137
+ await update($, isSummarizing, () => false)
138
+ }
139
+ }
140
+
141
+ // Summarizes once when the pane shows and no summary exists yet, or the last try failed.
142
+ async function summarizeIfMissing($: EngineInterface) {
143
+ const current = await read($, summary)
144
+ if (!current || "error" in current) await summarize($)
145
+ }
146
+
147
+ // Opens the pane once in a resumed session, the first time it has a PR or item.
148
+ async function autoOpen($: EngineInterface) {
149
+ if (!isAutoOpenWanted || isAutoOpened) return
150
+ await refreshWork($)
151
+ const current = await read($, work)
152
+ if (
153
+ isAutoOpened ||
154
+ !current ||
155
+ current.prs.length + current.items.length === 0
156
+ )
157
+ return
158
+ isAutoOpened = true
159
+ // No focus: the prompt keeps the keyboard. Ctrl+X Tab or a click focuses it to scroll.
160
+ await $.ui.open({ id: PANE, title: "Work", columns: 64 })
161
+ await summarizeIfMissing($)
162
+ }
163
+
164
+ // A conversation is resumed when it has turns no prompt of this process made:
165
+ // --resume and --continue load them before session.start, while the picker and
166
+ // an in-session /resume load them later, under a new session id.
167
+ async function checkResumed($: EngineInterface) {
168
+ const id = await $.session.id()
169
+ if (id !== watchedId) {
170
+ watchedId = id
171
+ hasPromptedHere = false
172
+ isAutoOpenWanted = false
173
+ isAutoOpened = false
174
+ // Another conversation now: its branches start over from here.
175
+ sessionStartedAt = Date.now()
176
+ await update($, branches, () => [])
177
+ }
178
+ if (isAutoOpenWanted || hasPromptedHere || (await $.session.turns()) === 0)
179
+ return
180
+ isAutoOpenWanted = true
181
+ await autoOpen($)
182
+ }
183
+
184
+ function ago(at: number): string {
185
+ const s = Math.max(0, Math.round((Date.now() - at) / 1000))
186
+ if (s < 60) return `${s}s ago`
187
+ if (s < 3600) return `${Math.round(s / 60)}m ago`
188
+ return `${Math.round(s / 3600)}h ago`
189
+ }
190
+
191
+ const STATE_COLOR: Record<string, string> = {
192
+ open: "success",
193
+ draft: "subtle",
194
+ merged: "merged",
195
+ closed: "error",
196
+ done: "success",
197
+ resolved: "success",
198
+ removed: "subtle",
199
+ "to do": "subtle",
200
+ new: "subtle",
201
+ active: "suggestion",
202
+ committed: "suggestion",
203
+ "in progress": "suggestion",
204
+ }
205
+ const CHECKS: Record<CheckState, { text: string; color: string }> = {
206
+ passing: { text: "✓ checks", color: "success" },
207
+ failing: { text: "✗ checks", color: "error" },
208
+ pending: { text: "● checks", color: "warning" },
209
+ none: { text: "no checks", color: "subtle" },
210
+ }
211
+ const STEP: Record<Step["status"], { mark: string; color: string }> = {
212
+ done: { mark: "✓", color: "success" },
213
+ current: { mark: "●", color: "warning" },
214
+ todo: { mark: "○", color: "subtle" },
215
+ }
216
+
217
+ export const register: Register = (on) => {
218
+ on("session.start", async ($, e, next) => {
219
+ await $.command.register({
220
+ name: "work",
221
+ description: "Show this session's PRs, work items and summary in a pane",
222
+ })
223
+ void checkResumed($)
224
+ $.clock.every(5_000, () => checkResumed($))
225
+ $.clock.every(60_000, async () => {
226
+ if (await isPaneOpen($)) await refreshWork($)
227
+ })
228
+
229
+ return next(e)
230
+ })
231
+
232
+ on("turn.complete", async ($, e, next) => {
233
+ const result = await next(e)
234
+ if (e.agentId) return result
235
+ // A main-conversation turn is when the session pushes branches and opens PRs.
236
+ void (async () => {
237
+ await noteBranch($)
238
+ if (isAutoOpenWanted && !isAutoOpened) return autoOpen($)
239
+ if (await isPaneOpen($)) await refreshWork($)
240
+ })()
241
+
242
+ return result
243
+ })
244
+
245
+ on("prompt.submit", async (_$, e, next) => {
246
+ hasPromptedHere = true
247
+
248
+ return next(e)
249
+ })
250
+
251
+ on("command.run", { command: "work" }, async ($) => {
252
+ void refreshWork($)
253
+ await $.ui.open({
254
+ id: PANE,
255
+ title: "Work",
256
+ columns: 64,
257
+ focus: true,
258
+ closeOnEscape: true,
259
+ })
260
+ void summarizeIfMissing($)
261
+
262
+ return {
263
+ text: "Work pane opened. Up/Down or PgUp/PgDn scroll, Esc closes.",
264
+ }
265
+ })
266
+
267
+ on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
268
+ const { Box, Text, Link, Button } = $.ui.resolve(e)
269
+ const current = await read($, work)
270
+ const fetching = await read($, isFetching)
271
+ const sum = await read($, summary)
272
+ const summarizing = await read($, isSummarizing)
273
+ const shut = await read($, collapsed)
274
+
275
+ const toggle = (pr: Pr) =>
276
+ update($, collapsed, (list) =>
277
+ list.includes(pr.number)
278
+ ? list.filter((n) => n !== pr.number)
279
+ : [...list, pr.number],
280
+ )
281
+ const prs = current?.prs ?? []
282
+ const items = current?.items ?? []
283
+
284
+ return (
285
+ <Box flexDirection="column" rowGap={1}>
286
+ <Box flexDirection="column">
287
+ <Text>
288
+ <Text bold color="planMode">
289
+ Summary
290
+ </Text>
291
+ <Text dimColor>
292
+ {summarizing ? " updating…" : sum ? ` ${ago(sum.at)}` : ""}
293
+ </Text>
294
+ </Text>
295
+ {!sum && (
296
+ <Text dimColor>Press Refresh to summarize this session.</Text>
297
+ )}
298
+ {sum && "error" in sum && <Text color="error">{sum.error}</Text>}
299
+ {sum && "goal" in sum && <Text>{sum.goal}</Text>}
300
+ </Box>
301
+
302
+ {sum && "steps" in sum && sum.steps.length > 0 && (
303
+ <Box flexDirection="column">
304
+ <Text bold color="planMode">
305
+ Steps
306
+ </Text>
307
+ {sum.steps.map((step) => (
308
+ <Text
309
+ wrap="truncate-end"
310
+ color={
311
+ step.status === "todo" ? undefined : STEP[step.status].color
312
+ }
313
+ dimColor={step.status === "todo"}
314
+ bold={step.status === "current"}
315
+ >
316
+ {STEP[step.status].mark} {step.text}
317
+ </Text>
318
+ ))}
319
+ </Box>
320
+ )}
321
+
322
+ <Box flexDirection="column">
323
+ <Text>
324
+ <Text bold color="planMode">
325
+ Pull requests
326
+ </Text>
327
+ <Text dimColor>
328
+ {" "}
329
+ {prs.length}
330
+ {fetching ? " · updating…" : ""}
331
+ </Text>
332
+ </Text>
333
+ {current?.error && <Text color="error">{current.error}</Text>}
334
+ {prs.length === 0 && !current?.error && (
335
+ <Text dimColor>None found for this session yet.</Text>
336
+ )}
337
+ {prs.map((pr) => (
338
+ <Box flexDirection="column">
339
+ <Box flexDirection="row" columnGap={1}>
340
+ <Button
341
+ key={`toggle-${pr.number}`}
342
+ plain
343
+ label={shut.includes(pr.number) ? "▸" : "▾"}
344
+ onPress={() => void toggle(pr)}
345
+ />
346
+ <Text wrap="truncate-end">
347
+ <Link href={pr.url}>#{pr.number}</Link>
348
+ <Text color={STATE_COLOR[pr.state] ?? "text"}>
349
+ {" "}
350
+ {pr.state}
351
+ </Text>
352
+ <Text dimColor> [{pr.branch}]</Text> {pr.title}
353
+ </Text>
354
+ </Box>
355
+ {!shut.includes(pr.number) && (
356
+ <Text>
357
+ {" "}
358
+ {pr.review && (
359
+ <Text
360
+ color={pr.review === "approved" ? "success" : "warning"}
361
+ >
362
+ {pr.review}{" "}
363
+ </Text>
364
+ )}
365
+ <Text color={CHECKS[pr.checks].color}>
366
+ {CHECKS[pr.checks].text}
367
+ </Text>
368
+ <Text dimColor> {pr.comments} comments</Text>
369
+ </Text>
370
+ )}
371
+ </Box>
372
+ ))}
373
+ </Box>
374
+
375
+ <Box flexDirection="column">
376
+ <Text>
377
+ <Text bold color="planMode">
378
+ Work items
379
+ </Text>
380
+ <Text dimColor> {items.length}</Text>
381
+ </Text>
382
+ {items.length === 0 && <Text dimColor>None linked yet.</Text>}
383
+ {items.map((item) => (
384
+ <Text wrap="truncate-end">
385
+ <Link href={item.url}>#{item.id}</Link>
386
+ <Text color={STATE_COLOR[item.state] ?? "text"}>
387
+ {" "}
388
+ {item.state}
389
+ </Text>{" "}
390
+ {item.title}
391
+ </Text>
392
+ ))}
393
+ </Box>
394
+
395
+ <Box flexDirection="row" columnGap={1}>
396
+ <Button
397
+ key="refresh"
398
+ label="Refresh"
399
+ hotkey="r"
400
+ onPress={() => void Promise.all([refreshWork($), summarize($)])}
401
+ />
402
+ <Text dimColor>
403
+ {current
404
+ ? `PRs updated ${ago(current.fetchedAt)}`
405
+ : "PRs not fetched yet"}
406
+ </Text>
407
+ </Box>
408
+ </Box>
409
+ )
410
+ })
411
+ }
@@ -0,0 +1,306 @@
1
+ import type { On, SessionMessage } from "claude-code"
2
+ import { expect, mock, test } from "claude-code/testing"
3
+
4
+ import {
5
+ azureChecks,
6
+ itemRefs,
7
+ parseSummary,
8
+ prNumbersIn,
9
+ } from "../hooks/discover"
10
+
11
+ const message = (text: string, output = ""): SessionMessage => ({
12
+ role: "assistant",
13
+ text,
14
+ toolUses: [],
15
+ toolResults: output
16
+ ? [{ tool_use_id: "t1", text: output, isError: false }]
17
+ : [],
18
+ })
19
+
20
+ test("PR links count only when they point into this repository", async () => {
21
+ const messages = [
22
+ message(
23
+ "see https://github.com/o/repo/pull/12",
24
+ "https://github.com/o/repo/pull/7\n",
25
+ ),
26
+ message("other repo https://github.com/o/elsewhere/pull/99"),
27
+ ]
28
+ expect(prNumbersIn(messages, "github", "repo").sort()).toEqual([12, 7].sort())
29
+ const azure = [
30
+ message("https://dev.azure.com/org/proj/_git/My%20Repo/pullrequest/19668"),
31
+ ]
32
+ expect(prNumbersIn(azure, "azure", "my repo")).toEqual([19668])
33
+ })
34
+
35
+ test("item refs come from the branch name and the branch commits", async () => {
36
+ expect(
37
+ itemRefs("azure", "user/me/43553/feat/x", "fix AB#44429 and AB#43553"),
38
+ ).toEqual([43553, 44429])
39
+ expect(itemRefs("github", "41-fix-v2", "closes #8")).toEqual([41, 8])
40
+ expect(itemRefs("github", "release/2.4", "")).toEqual([])
41
+ })
42
+
43
+ test("checks and summaries parse defensively", async () => {
44
+ expect(azureChecks(["approved", "queued"])).toBe("pending")
45
+ expect(azureChecks(["approved", "rejected"])).toBe("failing")
46
+ expect(azureChecks([])).toBe("none")
47
+ expect(
48
+ parseSummary(
49
+ '```json\n{"goal":"G","steps":[{"text":"a","status":"done"},{"text":"b","status":"weird"}]}\n```',
50
+ ),
51
+ ).toEqual({
52
+ goal: "G",
53
+ steps: [
54
+ { text: "a", status: "done" },
55
+ { text: "b", status: "todo" },
56
+ ],
57
+ })
58
+ expect(parseSummary("no json here")).toBeNull()
59
+ })
60
+
61
+ const GRAPHQL = JSON.stringify({
62
+ data: {
63
+ repository: {
64
+ b0: {
65
+ nodes: [
66
+ {
67
+ number: 7,
68
+ title: "Add the thing",
69
+ url: "https://github.com/o/repo/pull/7",
70
+ state: "OPEN",
71
+ isDraft: false,
72
+ reviewDecision: "REVIEW_REQUIRED",
73
+ headRefName: "feat/x",
74
+ comments: { totalCount: 1 },
75
+ reviewThreads: { totalCount: 2 },
76
+ commits: {
77
+ nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }],
78
+ },
79
+ closingIssuesReferences: {
80
+ nodes: [
81
+ {
82
+ number: 5,
83
+ title: "The bug",
84
+ url: "https://github.com/o/repo/issues/5",
85
+ state: "OPEN",
86
+ },
87
+ ],
88
+ },
89
+ },
90
+ ],
91
+ },
92
+ p12: {
93
+ number: 12,
94
+ title: "Stacked slice",
95
+ url: "https://github.com/o/repo/pull/12",
96
+ state: "MERGED",
97
+ isDraft: false,
98
+ reviewDecision: "APPROVED",
99
+ headRefName: "feat/y",
100
+ comments: { totalCount: 0 },
101
+ reviewThreads: { totalCount: 0 },
102
+ commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
103
+ closingIssuesReferences: { nodes: [] },
104
+ },
105
+ },
106
+ },
107
+ })
108
+
109
+ const STDOUT: Record<string, string> = {
110
+ "git remote": "git@github.com:o/repo.git\n",
111
+ "git branch": "feat/x\n",
112
+ "git symbolic-ref": "origin/main\n",
113
+ "git log": "",
114
+ "gh api": GRAPHQL,
115
+ }
116
+
117
+ function fakeWorld(
118
+ on: On,
119
+ opened: string[] = [],
120
+ messages: SessionMessage[] = [
121
+ message("opened https://github.com/o/repo/pull/12"),
122
+ ],
123
+ stdout: (argv: readonly string[]) => string | undefined = () => undefined,
124
+ ) {
125
+ const prompts: string[] = []
126
+ on("process.run", (_$, e) => ({
127
+ value: {
128
+ exitCode: 0,
129
+ stdout: stdout(e.argv) ?? STDOUT[`${e.argv[0]} ${e.argv[1]}`] ?? "",
130
+ stderr: "",
131
+ isStdoutTruncated: false,
132
+ isStderrTruncated: false,
133
+ },
134
+ }))
135
+ on("session.messages", () => ({ value: messages }))
136
+ on("model.complete", (_$, e) => {
137
+ prompts.push(String(e.prompt))
138
+ return {
139
+ value: {
140
+ isAnswered: true,
141
+ text: '{"goal":"Ship the thing.","steps":[{"text":"Write it","status":"done"},{"text":"Review it","status":"current"},{"text":"Merge it","status":"todo"}]}',
142
+ usage: {
143
+ input_tokens: 1,
144
+ output_tokens: 1,
145
+ cache_read_input_tokens: 0,
146
+ cache_creation_input_tokens: 0,
147
+ },
148
+ },
149
+ }
150
+ })
151
+ on("ui.open", (_$, e) => {
152
+ opened.push(e.id)
153
+ return { value: { isPlaced: true } }
154
+ })
155
+ on("ui.panes", () => ({ value: [] }))
156
+ on("command.register", (_$, e) => ({ value: { command: e.name } }))
157
+ on("session.start", (_$, e) => ({ cwd: e.cwd }))
158
+ return prompts
159
+ }
160
+
161
+ const PANE = {
162
+ plugin: "work-items",
163
+ component: "Pane",
164
+ requestId: "work-items",
165
+ props: {
166
+ title: "Work",
167
+ isFocused: true,
168
+ bodyColumns: 64,
169
+ placement: "dock",
170
+ scroll: { offset: 0, bodyRows: 30 },
171
+ view: {},
172
+ },
173
+ } as const
174
+
175
+ test("Refresh fills PRs, items and the summary, each PR linked", async ($, on) => {
176
+ const prompts = fakeWorld(on)
177
+ const first = await $.ui.mount({ ...PANE, surface: "terminal" })
178
+ expect(
179
+ await first.find({ type: "Text", text: /Press Refresh/ }),
180
+ ).toBeDefined()
181
+ await first.unmount()
182
+
183
+ for (const surface of ["terminal", "desktop"] as const) {
184
+ const ui = await $.ui.mount({ ...PANE, surface })
185
+ await ui.press({ key: "refresh" })
186
+
187
+ const links = await ui.findAll({ type: "Link" })
188
+ expect(links.map((link) => link.props.href)).toEqual([
189
+ "https://github.com/o/repo/pull/7",
190
+ "https://github.com/o/repo/pull/12",
191
+ "https://github.com/o/repo/issues/5",
192
+ ])
193
+ expect(
194
+ await ui.find({ type: "Text", text: /review required/ }),
195
+ ).toBeDefined()
196
+ expect(await ui.find({ type: "Text", text: /3 comments/ })).toBeDefined()
197
+ expect(
198
+ await ui.find({ type: "Text", text: /Ship the thing\./ }),
199
+ ).toBeDefined()
200
+ expect(await ui.find({ type: "Text", text: /● Review it/ })).toBeDefined()
201
+ expect(await ui.find({ type: "Text", text: /○ Merge it/ })).toBeDefined()
202
+ expect(
203
+ await ui.find({ type: "Text", text: /^PRs updated \d+s ago$/ }),
204
+ ).toBeDefined()
205
+
206
+ await ui.press({ key: "toggle-7" })
207
+ expect(await ui.find({ type: "Text", text: /3 comments/ })).toBeUndefined()
208
+ await ui.press({ key: "toggle-7" })
209
+ await ui.unmount()
210
+ }
211
+ expect(prompts.length).toBe(2)
212
+ expect(prompts[0]).toContain("pull/12")
213
+ })
214
+
215
+ test("Refresh skips the model while the session has no messages", async ($, on) => {
216
+ const prompts = fakeWorld(on, [], [])
217
+ const ui = await $.ui.mount({ ...PANE, surface: "terminal" })
218
+ await ui.press({ key: "refresh" })
219
+ expect(prompts).toEqual([])
220
+ expect(
221
+ await ui.find({ type: "Text", text: /Nothing to summarize yet/ }),
222
+ ).toBeDefined()
223
+ await ui.unmount()
224
+ })
225
+
226
+ function fakeSession(on: On, start: { id: string; turns: number }) {
227
+ const session = { ...start }
228
+ on("session.id", () => ({ value: session.id }))
229
+ on("session.turns", () => ({ value: session.turns }))
230
+ return session
231
+ }
232
+
233
+ const START = {
234
+ cwd: "/repo",
235
+ surface: "terminal",
236
+ isInteractive: true,
237
+ } as const
238
+
239
+ test("a branch the session only started on drops off; one it committed to stays", async ($, on) => {
240
+ let branch = "old-pr"
241
+ const queried: string[][] = []
242
+ const committedTo = new Set<string>()
243
+ fakeWorld(on, [], [], (argv) => {
244
+ if (argv[0] === "git" && argv[1] === "branch") return `${branch}\n`
245
+ if (argv[0] === "git" && argv[1] === "log" && argv[2] === "-1")
246
+ return committedTo.has(argv.at(-1) ?? "") ? "abc\n" : ""
247
+ if (argv[0] === "gh") queried.push(argv.filter((arg) => /^b\d+=/.test(arg)))
248
+ return undefined
249
+ })
250
+ const ui = await $.ui.mount({ ...PANE, surface: "terminal" })
251
+ await ui.press({ key: "refresh" })
252
+ expect(queried.at(-1)).toEqual(["b0=old-pr"])
253
+
254
+ branch = "stacked"
255
+ committedTo.add("stacked")
256
+ await ui.press({ key: "refresh" })
257
+ branch = "new-feature"
258
+ await ui.press({ key: "refresh" })
259
+ expect(queried.at(-1)).toEqual(["b0=new-feature", "b1=stacked"])
260
+ await ui.unmount()
261
+ })
262
+
263
+ test("a new session never opens the pane by itself, even after its own turns", async ($, on) => {
264
+ const clock = mock.clock(on)
265
+ const opened: string[] = []
266
+ fakeWorld(on, opened)
267
+ const session = fakeSession(on, { id: "a", turns: 0 })
268
+ on("prompt.submit", (_$, e) => ({ text: e.text }))
269
+ await $.session.start(START)
270
+ await clock.settle()
271
+ await $.prompt.submit({
272
+ text: "hi",
273
+ wait: false,
274
+ origin: { kind: "composer" },
275
+ })
276
+ session.turns = 1
277
+ await clock.advance(10_000)
278
+ expect(opened).toEqual([])
279
+ })
280
+
281
+ test("--resume opens the pane at start and summarizes once", async ($, on) => {
282
+ const clock = mock.clock(on)
283
+ const opened: string[] = []
284
+ const prompts = fakeWorld(on, opened)
285
+ fakeSession(on, { id: "a", turns: 4 })
286
+ await $.session.start(START)
287
+ await clock.settle()
288
+ expect(opened).toEqual(["work-items"])
289
+ expect(prompts.length).toBe(1)
290
+ await clock.advance(10_000)
291
+ expect(opened).toEqual(["work-items"])
292
+ })
293
+
294
+ test("the picker and /resume open the pane once the resumed turns load", async ($, on) => {
295
+ const clock = mock.clock(on)
296
+ const opened: string[] = []
297
+ fakeWorld(on, opened)
298
+ const session = fakeSession(on, { id: "picker", turns: 0 })
299
+ await $.session.start(START)
300
+ await clock.settle()
301
+ expect(opened).toEqual([])
302
+ session.id = "resumed"
303
+ session.turns = 6
304
+ await clock.advance(5_000)
305
+ expect(opened).toEqual(["work-items"])
306
+ })