@kud/gh-cockpit 0.0.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/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/chunk-A3NXVFEI.js +311 -0
- package/dist/extensions/index.d.ts +7 -0
- package/dist/extensions/index.js +69 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +624 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erwann Mest
|
|
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,162 @@
|
|
|
1
|
+
# @kud/gh-cockpit
|
|
2
|
+
|
|
3
|
+
A configurable GitHub cockpit for the terminal — your PRs, reviews and issues in one Ink TUI, grouped by **whose move it is**.
|
|
4
|
+
|
|
5
|
+
A tab tells you which relationship you are looking at. It never told you whether there was anything to _do_ once you got there, so red CI, merge conflicts and drafts sat interleaved with the rows you could actually act on. This bands every tab into `Your move` / `Their move` instead.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Review (20)
|
|
9
|
+
|
|
10
|
+
» Your move (2)
|
|
11
|
+
── acme/api-gateway ─────
|
|
12
|
+
◆ ← #1495 support statementPeriodIds… 6d
|
|
13
|
+
── acme/event-bus ───────
|
|
14
|
+
· #290 delete the legacy path 14h
|
|
15
|
+
|
|
16
|
+
» Their move (9)
|
|
17
|
+
── acme/monorepo ────────
|
|
18
|
+
~ #10 add internalNotes field 1w
|
|
19
|
+
── acme/api-gateway ─────
|
|
20
|
+
✗ ← #1496 point history at the route 2d
|
|
21
|
+
! #1449 guard null ids 6w
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install @kud/gh-cockpit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It is a **library, not a CLI**: you write the thin entry point, because the interesting decisions — which searches are tabs, which repos rank first, what a check drills into — are yours and not portable.
|
|
31
|
+
|
|
32
|
+
## Whose move is it
|
|
33
|
+
|
|
34
|
+
The band comes from the row's health _and_ where you stand relative to it. Those are different questions, and the same token means opposite things depending on the answer:
|
|
35
|
+
|
|
36
|
+
| | `authored`<br>your PR | `queued`<br>a review was asked of you | `spoken`<br>you already reviewed |
|
|
37
|
+
| ----------------------------------------------------- | --------------------- | ------------------------------------- | -------------------------------- |
|
|
38
|
+
| `✗` CI failing · `!` conflict · `±` changes requested | **you** | them | them |
|
|
39
|
+
| `·` awaiting review · `*` checks running | them | **you** | them |
|
|
40
|
+
| `✓` approved | **you** | **you** | them |
|
|
41
|
+
| `◆` open threads | **you** | **you** | **you** |
|
|
42
|
+
| `~` draft | them | them | them |
|
|
43
|
+
|
|
44
|
+
Red CI on a PR you wrote is your afternoon. The same red CI on one you were asked to review is the author's, and reviewing it is wasted. Nothing on the row records that difference — it is a property of the _search_ the row arrived from, which is why `standing` is declared per search rather than inferred.
|
|
45
|
+
|
|
46
|
+
> [!NOTE]
|
|
47
|
+
> Every state has its own glyph, never a colour alone. Colour reinforces; it never carries meaning by itself.
|
|
48
|
+
|
|
49
|
+
## Configure
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { defineCockpit } from "@kud/gh-cockpit"
|
|
53
|
+
import { delegateExtension } from "@kud/gh-cockpit/extensions"
|
|
54
|
+
|
|
55
|
+
export default defineCockpit({
|
|
56
|
+
repoPriority: ["acme/monorepo", "acme/", "me/"],
|
|
57
|
+
tabs: [
|
|
58
|
+
{
|
|
59
|
+
label: "Mine",
|
|
60
|
+
help: "your PRs, draft and open",
|
|
61
|
+
searches: ["is:open author:@me"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: "Review",
|
|
65
|
+
help: "theirs — asked of you, or you reviewed",
|
|
66
|
+
searches: [
|
|
67
|
+
{ q: "is:open review-requested:@me", standing: "queued" },
|
|
68
|
+
{
|
|
69
|
+
q: "is:open reviewed-by:@me -author:@me -review-requested:@me",
|
|
70
|
+
standing: "spoken",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
extensions: [delegateExtension],
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`defineCockpit` is identity, but typed — a config file is checked against the schema at build time rather than producing an empty tab at runtime.
|
|
80
|
+
|
|
81
|
+
### Repo priority
|
|
82
|
+
|
|
83
|
+
Ordered, best first. An entry ending in `/` matches an owner; anything else must equal `owner/name`, so a single repo can outrank the owner containing it. Repos matching nothing sort last, together.
|
|
84
|
+
|
|
85
|
+
Grouping is a separate key from ranking: repos stay clustered whatever the priority list says, because repo headers depend on same-repo rows being adjacent.
|
|
86
|
+
|
|
87
|
+
## Filtering
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
gh-cockpit --include 'acme/*,me/acme-*'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`*` matches within one path segment and never across the `/`, so `acme/*` cannot reach another owner and `acme` will not claim `acmecorp`. A bare owner is sugar for all its repos.
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
gh-cockpit --exclude 'acme/legacy'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Exclude is applied after include and wins, so you can take a whole org and drop one repo without listing the rest.
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
gh-cockpit --here
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--here` is not a filter. It resolves the repo from your git remote — `upstream` if present, else `origin` — and scopes every search **server-side** with `repo:owner/name`, so other repos are never fetched at all. On a fork that matters: `origin` is your copy and `upstream` is where the PRs actually live.
|
|
106
|
+
|
|
107
|
+
## Extensions
|
|
108
|
+
|
|
109
|
+
Extensions live behind their own entry point because they are a choice, not a default — each one claims a keybinding on a surface where every key is already spoken for.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import {
|
|
113
|
+
delegateExtension,
|
|
114
|
+
copyPromptExtension,
|
|
115
|
+
} from "@kud/gh-cockpit/extensions"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Both act on the row under the cursor and shell out to nothing, so neither assumes anything about what you have installed.
|
|
119
|
+
|
|
120
|
+
## Check drill-ins
|
|
121
|
+
|
|
122
|
+
Activating a CI check opens its log. GitHub Actions is built in; anything else is yours to register, and an unregistered check opens in a browser rather than drilling into a view that renders nothing.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { registerCheckDrills } from "@kud/gh-cockpit"
|
|
126
|
+
|
|
127
|
+
registerCheckDrills([
|
|
128
|
+
{
|
|
129
|
+
match: (url) => url.includes("ci.example"),
|
|
130
|
+
render: ({ url, name, onBack }) => <MyBuildView url={url} name={name} onBack={onBack} />,
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Host configuration
|
|
136
|
+
|
|
137
|
+
The library holds no opinion about your machine. `configureInbox` (from `@kud/gh-ink`) is where you supply one, and **every default is empty** — an unconfigured host gets flat repo ranking and no local-checkout resolution, never a guess at where you keep your code.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { configureInbox } from "@kud/gh-cockpit"
|
|
141
|
+
|
|
142
|
+
configureInbox({
|
|
143
|
+
repoPriority: ["acme/", "me/"],
|
|
144
|
+
checkoutDir: `${process.env.HOME}/src`,
|
|
145
|
+
cacheNamespace: "my-cockpit",
|
|
146
|
+
cacheTtlMs: 20 * 60_000,
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> [!TIP]
|
|
151
|
+
> `cacheTtlMs` is the single knob between "always current" and "always slow, and drawing 502s from the API" — every launch past it pays the full query. It can be generous: acting on a row drops the cache entry outright, and `r` refetches on demand.
|
|
152
|
+
|
|
153
|
+
## Refreshing
|
|
154
|
+
|
|
155
|
+
The cockpit does not poll. A poller spends quota on the long stretches where nothing changed and is still up to a full interval late when something did. Instead, pass `watchPath` and have whatever mutates GitHub write a byte to that file — every open cockpit refetches, debounced. The filesystem is the broker: no daemon, no socket, no fan-out to manage.
|
|
156
|
+
|
|
157
|
+
> [!IMPORTANT]
|
|
158
|
+
> Write a byte rather than `touch`ing. At second-granularity mtime with no size change, two touches inside the same second are indistinguishable and some watch backends coalesce them away.
|
|
159
|
+
|
|
160
|
+
## Licence
|
|
161
|
+
|
|
162
|
+
MIT © Erwann Mest
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as gh_ink_star from '@kud/gh-ink';
|
|
2
|
+
import { relativeTime, inboxConfig } from '@kud/gh-ink';
|
|
3
|
+
import { mkdirSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { buildInboxQuery, computeHealth as computeHealth$1, latestChecks, isPendingCheck, isFailCheck, isPassCheck } from '@kud/gh';
|
|
7
|
+
export { buildInboxQuery } from '@kud/gh';
|
|
8
|
+
import { useWindowSize, Box, Text, useInput } from 'ink';
|
|
9
|
+
import { Panel, colors, FooterHints, useListCursor } from '@kud/ink-ui';
|
|
10
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
11
|
+
import { $ } from 'zx';
|
|
12
|
+
import { useRef, useEffect, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
var __defProp = Object.defineProperty;
|
|
15
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
16
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
17
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
18
|
+
var __export = (target, all) => {
|
|
19
|
+
for (var name in all)
|
|
20
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
21
|
+
};
|
|
22
|
+
var __copyProps = (to, from, except, desc) => {
|
|
23
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
24
|
+
for (let key of __getOwnPropNames(from))
|
|
25
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
26
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
27
|
+
}
|
|
28
|
+
return to;
|
|
29
|
+
};
|
|
30
|
+
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
31
|
+
|
|
32
|
+
// src/lib.ts
|
|
33
|
+
var lib_exports = {};
|
|
34
|
+
__export(lib_exports, {
|
|
35
|
+
buildInboxQuery: () => buildInboxQuery,
|
|
36
|
+
computeHealth: () => computeHealth,
|
|
37
|
+
signalPath: () => signalPath,
|
|
38
|
+
toGHItem: () => toGHItem,
|
|
39
|
+
withRetry: () => withRetry
|
|
40
|
+
});
|
|
41
|
+
__reExport(lib_exports, gh_ink_star);
|
|
42
|
+
var withRetry = async (fn, attempts = 3, delayMs = 1e3) => {
|
|
43
|
+
for (let i = 0; i < attempts; i++) {
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const msg = err.message ?? "";
|
|
48
|
+
const isTransient = /50[234]/.test(msg);
|
|
49
|
+
if (!isTransient || i === attempts - 1) throw err;
|
|
50
|
+
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error("unreachable");
|
|
54
|
+
};
|
|
55
|
+
var computeHealth = (node) => computeHealth$1({
|
|
56
|
+
state: node.state,
|
|
57
|
+
isDraft: "isDraft" in node ? node.isDraft : void 0,
|
|
58
|
+
checks: node.statusCheckRollup?.contexts?.nodes ?? [],
|
|
59
|
+
mergeable: node.mergeable,
|
|
60
|
+
reviewDecision: node.reviewDecision,
|
|
61
|
+
unresolvedThreads: (node.reviewThreads?.nodes ?? []).filter(
|
|
62
|
+
(t) => t && !t.isResolved
|
|
63
|
+
).length
|
|
64
|
+
});
|
|
65
|
+
var conversationOf = (node) => {
|
|
66
|
+
const events = [];
|
|
67
|
+
let count = node.comments?.totalCount ?? 0;
|
|
68
|
+
const isBot = (author) => author?.__typename === "Bot";
|
|
69
|
+
for (const c of node.comments?.nodes ?? [])
|
|
70
|
+
events.push({
|
|
71
|
+
at: c.createdAt,
|
|
72
|
+
login: c.author?.login,
|
|
73
|
+
bot: isBot(c.author)
|
|
74
|
+
});
|
|
75
|
+
for (const r of node.reviews?.nodes ?? [])
|
|
76
|
+
if (r.state !== "PENDING")
|
|
77
|
+
events.push({
|
|
78
|
+
at: r.submittedAt,
|
|
79
|
+
login: r.author?.login,
|
|
80
|
+
bot: isBot(r.author)
|
|
81
|
+
});
|
|
82
|
+
for (const t of node.reviewThreads?.nodes ?? []) {
|
|
83
|
+
count += t.comments?.totalCount ?? 0;
|
|
84
|
+
for (const c of t.comments?.nodes ?? [])
|
|
85
|
+
events.push({
|
|
86
|
+
at: c.createdAt,
|
|
87
|
+
login: c.author?.login,
|
|
88
|
+
bot: isBot(c.author)
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const last = events.filter((e) => e.at).sort((a, b) => a.at.localeCompare(b.at)).pop();
|
|
92
|
+
const pushedAt = node.commits?.nodes?.[0]?.commit?.committedDate;
|
|
93
|
+
if (last?.bot && pushedAt && pushedAt > last.at)
|
|
94
|
+
return { count, lastActor: node.author?.login, lastEventAt: pushedAt };
|
|
95
|
+
return { count, lastActor: last?.login, lastEventAt: last?.at };
|
|
96
|
+
};
|
|
97
|
+
var detailOf = (node, lastEventAt) => {
|
|
98
|
+
const active = latestChecks(node.statusCheckRollup?.contexts?.nodes ?? []);
|
|
99
|
+
return {
|
|
100
|
+
reviewDecision: node.reviewDecision ?? void 0,
|
|
101
|
+
mergeable: node.mergeable ?? void 0,
|
|
102
|
+
checksPass: active.filter(isPassCheck).length,
|
|
103
|
+
checksFail: active.filter(isFailCheck).length,
|
|
104
|
+
checksPending: active.filter(isPendingCheck).length,
|
|
105
|
+
threadsTotal: (node.reviewThreads?.nodes ?? []).length,
|
|
106
|
+
lastCommitAt: node.commits?.nodes?.[0]?.commit?.committedDate,
|
|
107
|
+
lastEventAt
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
var toGHItem = (node, opts = {}) => {
|
|
111
|
+
const completedAt = node.mergedAt ?? node.closedAt ?? node.createdAt;
|
|
112
|
+
const convo = conversationOf(node);
|
|
113
|
+
const activityAt = [
|
|
114
|
+
node.createdAt,
|
|
115
|
+
convo.lastEventAt,
|
|
116
|
+
node.commits?.nodes?.[0]?.commit?.committedDate
|
|
117
|
+
].filter(Boolean).sort().pop();
|
|
118
|
+
const isDone = Boolean(node.mergedAt ?? node.closedAt);
|
|
119
|
+
const sortAt = isDone ? completedAt : activityAt ?? completedAt;
|
|
120
|
+
return {
|
|
121
|
+
// Prefer GraphQL's own discriminator; fall back to field-presence only when
|
|
122
|
+
// a fragment omitted __typename. Field-presence alone mislabels PRs as issues
|
|
123
|
+
// whenever a fragment (reviewRequests, assigned, reviewed…) skips isDraft /
|
|
124
|
+
// headRefName — which sent PR drills to the issues endpoint.
|
|
125
|
+
kind: node.__typename === "PullRequest" ? "pr" : node.__typename === "Issue" ? "issue" : "isDraft" in node || "headRefName" in node ? "pr" : "issue",
|
|
126
|
+
number: node.number,
|
|
127
|
+
title: node.title ?? "",
|
|
128
|
+
repo: node.repository?.nameWithOwner ?? "",
|
|
129
|
+
url: node.url ?? "",
|
|
130
|
+
branch: node.headRefName,
|
|
131
|
+
health: computeHealth(node),
|
|
132
|
+
author: node.author?.login,
|
|
133
|
+
age: completedAt ? relativeTime(completedAt) : "",
|
|
134
|
+
activityAge: !isDone && activityAt ? relativeTime(activityAt) : void 0,
|
|
135
|
+
ts: sortAt ? new Date(sortAt).getTime() : 0,
|
|
136
|
+
unresolved: (node.reviewThreads?.nodes ?? []).filter(
|
|
137
|
+
(t) => t && !t.isResolved
|
|
138
|
+
).length,
|
|
139
|
+
conversation: convo.count,
|
|
140
|
+
lastActor: convo.lastActor,
|
|
141
|
+
labels: (node.labels?.nodes ?? []).map((l) => l?.name).filter((n) => typeof n === "string"),
|
|
142
|
+
detail: detailOf(node, convo.lastEventAt),
|
|
143
|
+
indent: opts.indent ?? false
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
var signalPath = () => {
|
|
147
|
+
const dir = join(
|
|
148
|
+
process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
|
|
149
|
+
inboxConfig().cacheNamespace
|
|
150
|
+
);
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
return join(dir, "cockpit-dirty");
|
|
153
|
+
};
|
|
154
|
+
var DrillView = ({
|
|
155
|
+
title,
|
|
156
|
+
subtitle,
|
|
157
|
+
hints,
|
|
158
|
+
children
|
|
159
|
+
}) => {
|
|
160
|
+
const { rows } = useWindowSize();
|
|
161
|
+
return /* @__PURE__ */ jsx(Panel, { children: /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: rows - 2, paddingX: 1, children: [
|
|
162
|
+
/* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
|
|
163
|
+
/* @__PURE__ */ jsx(Text, { color: colors.accent, bold: true, children: title }),
|
|
164
|
+
subtitle ? /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
|
|
165
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: "Title: " }),
|
|
166
|
+
/* @__PURE__ */ jsx(Text, { children: subtitle })
|
|
167
|
+
] }) : null
|
|
168
|
+
] }),
|
|
169
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, children }),
|
|
170
|
+
/* @__PURE__ */ jsx(FooterHints, { hints })
|
|
171
|
+
] }) });
|
|
172
|
+
};
|
|
173
|
+
var CANDIDATES = [
|
|
174
|
+
{ id: "claude", label: "Claude Code", cmd: "claude", acceptsPrompt: true },
|
|
175
|
+
{ id: "opencode", label: "opencode", cmd: "opencode" },
|
|
176
|
+
{ id: "codex", label: "Codex", cmd: "codex", acceptsPrompt: true }
|
|
177
|
+
];
|
|
178
|
+
var PLACEMENTS = [
|
|
179
|
+
{ id: "here", label: "Right here" },
|
|
180
|
+
{ id: "tab", label: "New tab" },
|
|
181
|
+
{ id: "vpane", label: "New pane \u2192 (right)" },
|
|
182
|
+
{ id: "hpane", label: "New pane \u2193 (below)" }
|
|
183
|
+
];
|
|
184
|
+
var isInstalled = async (cmd) => (await $({ nothrow: true, quiet: true })`command -v ${cmd}`).exitCode === 0;
|
|
185
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
186
|
+
var seedPromptFor = (item) => item.kind === "pr" ? `/k-pr ${item.number}` : item.labels?.includes("plan") ? `/k-project plan ${item.number}` : `/k-project ${item.url}`;
|
|
187
|
+
var portablePromptFor = (item) => item.kind === "pr" ? `/k-pr ${item.url}` : item.labels?.includes("plan") ? `/k-project plan ${item.number} ${item.repo}` : `/k-project ${item.url}`;
|
|
188
|
+
var CopyPromptNotice = ({
|
|
189
|
+
item,
|
|
190
|
+
onBack
|
|
191
|
+
}) => {
|
|
192
|
+
const prompt = portablePromptFor(item);
|
|
193
|
+
const back = useRef(onBack);
|
|
194
|
+
back.current = onBack;
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
(0, lib_exports.clipboard)(prompt);
|
|
197
|
+
const timer = setTimeout(() => back.current(), 1400);
|
|
198
|
+
return () => clearTimeout(timer);
|
|
199
|
+
}, [prompt]);
|
|
200
|
+
useInput(() => back.current());
|
|
201
|
+
return /* @__PURE__ */ jsx(
|
|
202
|
+
DrillView,
|
|
203
|
+
{
|
|
204
|
+
title: `Copy prompt \xB7 #${item.number} \xB7 ${item.repo}`,
|
|
205
|
+
subtitle: "paste it into a session that is already running",
|
|
206
|
+
hints: [["any key", "back"]],
|
|
207
|
+
children: /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
208
|
+
/* @__PURE__ */ jsx(Text, { color: colors.success, children: "\u2713 copied to clipboard" }),
|
|
209
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: `prompt ${prompt}` }) })
|
|
210
|
+
] })
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
var AiLauncher = ({
|
|
215
|
+
item,
|
|
216
|
+
login,
|
|
217
|
+
prompt,
|
|
218
|
+
onBack
|
|
219
|
+
}) => {
|
|
220
|
+
const [agents, setAgents] = useState(null);
|
|
221
|
+
const [step, setStep] = useState("agent");
|
|
222
|
+
const [agent, setAgent] = useState(null);
|
|
223
|
+
const [note, setNote] = useState(null);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
let live = true;
|
|
226
|
+
Promise.all(
|
|
227
|
+
CANDIDATES.map(async (a) => await isInstalled(a.cmd) ? a : null)
|
|
228
|
+
).then((found) => {
|
|
229
|
+
if (!live) return;
|
|
230
|
+
const shell = { id: "shell", label: "Shell (no AI)", cmd: "" };
|
|
231
|
+
setAgents([...found.filter((a) => a !== null), shell]);
|
|
232
|
+
});
|
|
233
|
+
return () => {
|
|
234
|
+
live = false;
|
|
235
|
+
};
|
|
236
|
+
}, []);
|
|
237
|
+
const list = step === "agent" ? (agents ?? []).map((a) => ({ label: a.label, hint: a.cmd })) : PLACEMENTS.map((p) => ({ label: p.label }));
|
|
238
|
+
const { cursor, setCursor } = useListCursor(list.length);
|
|
239
|
+
const safeCursor = Math.min(cursor, Math.max(0, list.length - 1));
|
|
240
|
+
const focused = step === "agent" ? (agents ?? [])[safeCursor] : agent;
|
|
241
|
+
const launch = async (a, placement) => {
|
|
242
|
+
setNote(`\u22EF opening ${a.label}\u2026`);
|
|
243
|
+
try {
|
|
244
|
+
const base = await (0, lib_exports.buildCheckoutCmd)(item.repo, item.branch ?? "", login);
|
|
245
|
+
const run = prompt && a.acceptsPrompt ? `${a.cmd} ${shellQuote(prompt)}` : a.cmd;
|
|
246
|
+
const full = a.cmd ? `${base} && ${run}` : base;
|
|
247
|
+
if (placement === "here") {
|
|
248
|
+
(0, lib_exports.runHere)(full);
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
if (placement === "tab") await (0, lib_exports.openInTab)(full);
|
|
252
|
+
if (placement === "vpane") await (0, lib_exports.runInPane)(full);
|
|
253
|
+
if (placement === "hpane") await (0, lib_exports.runInPaneHorizontal)(full);
|
|
254
|
+
setNote(`\u2197 ${a.label} launched${item.branch ? ` \xB7 ${item.branch}` : ""}`);
|
|
255
|
+
setTimeout(onBack, 900);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
setNote(`\u2717 ${e.message}`);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
useInput((input, key) => {
|
|
261
|
+
if (key.escape || input === "q") {
|
|
262
|
+
if (step === "place") {
|
|
263
|
+
setStep("agent");
|
|
264
|
+
setCursor(0);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
return onBack();
|
|
268
|
+
}
|
|
269
|
+
if (key.return) {
|
|
270
|
+
if (step === "agent") {
|
|
271
|
+
setAgent((agents ?? [])[safeCursor] ?? null);
|
|
272
|
+
setStep("place");
|
|
273
|
+
setCursor(0);
|
|
274
|
+
} else if (agent) {
|
|
275
|
+
void launch(agent, PLACEMENTS[safeCursor].id);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
return /* @__PURE__ */ jsx(
|
|
280
|
+
DrillView,
|
|
281
|
+
{
|
|
282
|
+
title: `Run AI \xB7 #${item.number} \xB7 ${item.repo}`,
|
|
283
|
+
subtitle: step === "agent" ? "choose an agent" : `${agent?.label} \u2014 choose where`,
|
|
284
|
+
hints: [
|
|
285
|
+
["\u2191\u2193", "nav"],
|
|
286
|
+
["\u21B5", step === "agent" ? "choose" : "launch"],
|
|
287
|
+
["q/esc", step === "place" ? "back" : "close"]
|
|
288
|
+
],
|
|
289
|
+
children: !agents ? /* @__PURE__ */ jsx(Text, { color: colors.info, children: "Detecting agents\u2026" }) : /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
290
|
+
list.map((row, i) => /* @__PURE__ */ jsxs(Box, { children: [
|
|
291
|
+
/* @__PURE__ */ jsx(Text, { color: colors.info, children: i === safeCursor ? " \u276F " : " " }),
|
|
292
|
+
/* @__PURE__ */ jsx(Text, { bold: i === safeCursor, children: row.label }),
|
|
293
|
+
row.hint ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + row.hint }) : null
|
|
294
|
+
] }, row.label)),
|
|
295
|
+
prompt ? /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
296
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: `prompt ${prompt}` }),
|
|
297
|
+
focused && focused.cmd && !focused.acceptsPrompt ? /* @__PURE__ */ jsx(
|
|
298
|
+
Text,
|
|
299
|
+
{
|
|
300
|
+
dimColor: true,
|
|
301
|
+
children: ` ${focused.label} takes no prompt \u2014 starts cold`
|
|
302
|
+
}
|
|
303
|
+
) : null
|
|
304
|
+
] }) : null,
|
|
305
|
+
note ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: colors.success, children: note }) }) : null
|
|
306
|
+
] })
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export { AiLauncher, CopyPromptNotice, DrillView, __export, __reExport, computeHealth, lib_exports, seedPromptFor, signalPath, toGHItem, withRetry };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AiLauncher, seedPromptFor, CopyPromptNotice } from '../chunk-A3NXVFEI.js';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { jsx } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
var isDelegatable = (item) => item?.kind === "pr" || item?.kind === "issue";
|
|
6
|
+
var DelegateScreen = ({
|
|
7
|
+
item,
|
|
8
|
+
login,
|
|
9
|
+
onExit
|
|
10
|
+
}) => {
|
|
11
|
+
const delegatable = isDelegatable(item);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!delegatable) onExit();
|
|
14
|
+
}, [delegatable, onExit]);
|
|
15
|
+
if (!delegatable) return null;
|
|
16
|
+
return /* @__PURE__ */ jsx(
|
|
17
|
+
AiLauncher,
|
|
18
|
+
{
|
|
19
|
+
item,
|
|
20
|
+
login,
|
|
21
|
+
prompt: seedPromptFor(item),
|
|
22
|
+
onBack: onExit
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
var delegateExtension = {
|
|
27
|
+
id: "delegate",
|
|
28
|
+
title: "Delegate to an agent",
|
|
29
|
+
key: "a",
|
|
30
|
+
// Short enough for the footer strip, where "delegate to an agent" would crowd out
|
|
31
|
+
// the bindings either side of it. Matches the drill views' own `a AI` hint.
|
|
32
|
+
hint: "AI",
|
|
33
|
+
// Item-scoped: this acts on the selected row, so it earns a place in that row's
|
|
34
|
+
// action menu under `m`. Without this it would still work as a keypress and stay
|
|
35
|
+
// absent from the menu, which is exactly the gap that prompted 0.5.0.
|
|
36
|
+
scope: "item",
|
|
37
|
+
body: (onExit, target) => /* @__PURE__ */ jsx(
|
|
38
|
+
DelegateScreen,
|
|
39
|
+
{
|
|
40
|
+
item: target?.item,
|
|
41
|
+
login: target?.login ?? "",
|
|
42
|
+
onExit
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
};
|
|
46
|
+
var isCopyable = (item) => item?.kind === "pr" || item?.kind === "issue";
|
|
47
|
+
var CopyPromptScreen = ({
|
|
48
|
+
item,
|
|
49
|
+
onExit
|
|
50
|
+
}) => {
|
|
51
|
+
const copyable = isCopyable(item);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!copyable) onExit();
|
|
54
|
+
}, [copyable, onExit]);
|
|
55
|
+
if (!copyable) return null;
|
|
56
|
+
return /* @__PURE__ */ jsx(CopyPromptNotice, { item, onBack: onExit });
|
|
57
|
+
};
|
|
58
|
+
var copyPromptExtension = {
|
|
59
|
+
id: "copy-prompt",
|
|
60
|
+
title: "Copy prompt to clipboard",
|
|
61
|
+
// `y` for yank: `c` and `b` are gh-ink's own copy-URL and copy-branch, and `p`
|
|
62
|
+
// is open-repo-in-pane.
|
|
63
|
+
key: "y",
|
|
64
|
+
hint: "copy prompt",
|
|
65
|
+
scope: "item",
|
|
66
|
+
body: (onExit, target) => /* @__PURE__ */ jsx(CopyPromptScreen, { item: target?.item, onExit })
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export { copyPromptExtension, delegateExtension };
|