@quandev104/pi-style 0.1.4 → 0.1.5
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 +24 -0
- package/README.md +8 -4
- package/dist/extensions/pi-style.js +3789 -1278
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-normalization.ts +21 -5
- package/extension-src/pi-style/domain/config-presets.ts +1 -1
- package/extension-src/pi-style/domain/config-types.ts +7 -3
- package/extension-src/pi-style/domain/theme.ts +6 -1
- package/extension-src/pi-style/features/editor/index.ts +169 -27
- package/extension-src/pi-style/features/messages/index.ts +66 -0
- package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +154 -130
- package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
- package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
- package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
- package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
- package/extension-src/pi-style/features/tools/boxed/write.ts +2 -1
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
- package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
- package/extension-src/pi-style/pi/index.ts +18 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +24 -0
- package/extension-src/pi-style/shared/box.ts +8 -4
- package/extension-src/pi-style/shared/split-diff.ts +9 -9
- package/package.json +1 -1
- package/themes/titanium-light.json +82 -0
- package/themes/titanium.json +79 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
// GitHub (`gh`) semantic view renderer (Phase 8D).
|
|
2
|
+
//
|
|
3
|
+
// Bash `gh pr`/`issue`/`run` results render as a boxless compact card in the
|
|
4
|
+
// call panel, mirroring the git semantic path (see git.ts). `gh run view
|
|
5
|
+
// --job=<id>` renders the job log in a boxed result (the same
|
|
6
|
+
// `renderBoxedToolResult` shape git diff uses), while `gh run watch`, `gh api`,
|
|
7
|
+
// and any command with pipes/redirects/`&&` stay raw (ADR 0005). bash.ts owns
|
|
8
|
+
// the registry and dispatch; this module is registry-free pure functions.
|
|
9
|
+
//
|
|
10
|
+
// Every parser is fail-closed: on any ambiguity it returns null and the boxed
|
|
11
|
+
// command/response shell renders the raw output unchanged. `--json` output is
|
|
12
|
+
// detected by the first non-whitespace character (`{`/`[`); otherwise the
|
|
13
|
+
// table/rich text format is parsed.
|
|
14
|
+
|
|
15
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
16
|
+
import { type BoxTheme, dimLine, renderBoxedToolResult } from "../../../shared/box.js";
|
|
17
|
+
import { formatElapsedMs } from "../../../shared/elapsed.js";
|
|
18
|
+
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
19
|
+
import { parseSimpleBashCommand } from "./command-shape.js";
|
|
20
|
+
import { pluralForm, TREE_INDENT } from "./output-tree.js";
|
|
21
|
+
import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
|
|
22
|
+
import type { BoxedToolContext } from "./shared.js";
|
|
23
|
+
|
|
24
|
+
// ── Classification ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export type GhSemanticClass =
|
|
27
|
+
| { readonly kind: "pr-list" }
|
|
28
|
+
| { readonly kind: "pr-view" }
|
|
29
|
+
| { readonly kind: "pr-checks" }
|
|
30
|
+
| { readonly kind: "pr-create" }
|
|
31
|
+
| { readonly kind: "issue-list" }
|
|
32
|
+
| { readonly kind: "issue-view" }
|
|
33
|
+
| { readonly kind: "run-list" }
|
|
34
|
+
| { readonly kind: "run-view" }
|
|
35
|
+
| { readonly kind: "run-job"; readonly jobId: string };
|
|
36
|
+
|
|
37
|
+
/** Global `gh` flags that consume a separate value token (`-R owner/repo`). */
|
|
38
|
+
const GH_REPO_VALUE_FLAGS = new Set(["-R", "--repo"]);
|
|
39
|
+
|
|
40
|
+
/** Strip `-R <value>` / `--repo <value>` (and attached `--repo=value`) pairs so
|
|
41
|
+
* the command/subcommand words can be located anywhere in the arg list. */
|
|
42
|
+
function stripRepoFlags(args: readonly string[]): string[] {
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
for (let i = 0; i < args.length; ) {
|
|
45
|
+
const token = args[i] ?? "";
|
|
46
|
+
if (GH_REPO_VALUE_FLAGS.has(token)) {
|
|
47
|
+
i += 2; // flag + its value
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (token.startsWith("--repo=")) {
|
|
51
|
+
i += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out.push(token);
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Locate the `--job` value (`--job <id>` or `--job=<id>`) on a `run view`. */
|
|
61
|
+
function findJobId(args: readonly string[]): string | undefined {
|
|
62
|
+
for (let i = 0; i < args.length; i++) {
|
|
63
|
+
const token = args[i] ?? "";
|
|
64
|
+
if (token === "--job") return args[i + 1];
|
|
65
|
+
const attached = /^--job=(.+)$/.exec(token);
|
|
66
|
+
if (attached) return attached[1];
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify a bash command for `gh` semantic rendering, or null to keep the
|
|
73
|
+
* boxed shell. Only `gh pr {list,view,checks,create}`, `gh issue {list,view}`,
|
|
74
|
+
* and `gh run {list,view}` are eligible; `gh run view --job=<id>` becomes a
|
|
75
|
+
* `run-job` (boxed log). `gh run watch`, `gh api`, and any other subcommand or
|
|
76
|
+
* pipe/redirect fall back raw (ADR 0005).
|
|
77
|
+
*/
|
|
78
|
+
export function classifyGhCommand(command: string): GhSemanticClass | null {
|
|
79
|
+
const shape = parseSimpleBashCommand(command);
|
|
80
|
+
if (!shape) return null;
|
|
81
|
+
const rest = shape.tokens;
|
|
82
|
+
if ((rest[0] ?? "").split("/").pop() !== "gh") return null;
|
|
83
|
+
const args = rest.slice(1);
|
|
84
|
+
if (args.length === 0) return null;
|
|
85
|
+
|
|
86
|
+
const tokens = stripRepoFlags(args);
|
|
87
|
+
const commandWord = tokens[0];
|
|
88
|
+
const subcommand = tokens[1];
|
|
89
|
+
|
|
90
|
+
if (commandWord === "pr") {
|
|
91
|
+
if (subcommand === "list") return { kind: "pr-list" };
|
|
92
|
+
if (subcommand === "view") return { kind: "pr-view" };
|
|
93
|
+
if (subcommand === "checks") return { kind: "pr-checks" };
|
|
94
|
+
if (subcommand === "create") return { kind: "pr-create" };
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (commandWord === "issue") {
|
|
98
|
+
if (subcommand === "list") return { kind: "issue-list" };
|
|
99
|
+
if (subcommand === "view") return { kind: "issue-view" };
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (commandWord === "run") {
|
|
103
|
+
if (subcommand === "list") return { kind: "run-list" };
|
|
104
|
+
if (subcommand === "view") {
|
|
105
|
+
const jobId = findJobId(args);
|
|
106
|
+
if (jobId !== undefined) return { kind: "run-job", jobId };
|
|
107
|
+
return { kind: "run-view" };
|
|
108
|
+
}
|
|
109
|
+
// `gh run watch` and all other run subcommands stay raw (ADR 0005).
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return null; // `gh api`, extensions, and other subcommands
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Parsed shapes ───────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const GH_STATES = new Set(["OPEN", "CLOSED", "MERGED"]);
|
|
118
|
+
const GH_CHECK_STATES = new Set([
|
|
119
|
+
"pass",
|
|
120
|
+
"fail",
|
|
121
|
+
"pending",
|
|
122
|
+
"skipping",
|
|
123
|
+
"neutral",
|
|
124
|
+
"cancelled",
|
|
125
|
+
"timed_out",
|
|
126
|
+
"startup_failure",
|
|
127
|
+
"stale",
|
|
128
|
+
"action_required",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
export interface GhListItem {
|
|
132
|
+
readonly number: number;
|
|
133
|
+
readonly title: string;
|
|
134
|
+
readonly branch?: string;
|
|
135
|
+
readonly state: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface GhListParsed {
|
|
139
|
+
readonly kind: "pr-list" | "issue-list";
|
|
140
|
+
readonly rows: readonly GhListItem[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface GhViewParsed {
|
|
144
|
+
readonly kind: "pr-view" | "issue-view";
|
|
145
|
+
readonly title: string;
|
|
146
|
+
readonly state: string;
|
|
147
|
+
readonly author?: string;
|
|
148
|
+
readonly number?: number;
|
|
149
|
+
readonly url?: string;
|
|
150
|
+
readonly additions?: number;
|
|
151
|
+
readonly deletions?: number;
|
|
152
|
+
readonly changedFiles?: number;
|
|
153
|
+
readonly baseRefName?: string;
|
|
154
|
+
readonly headRefName?: string;
|
|
155
|
+
readonly reviewers?: string;
|
|
156
|
+
readonly reviewDecision?: string;
|
|
157
|
+
readonly mergeable?: string;
|
|
158
|
+
readonly labels?: string;
|
|
159
|
+
readonly body?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface GhCheckRow {
|
|
163
|
+
readonly name: string;
|
|
164
|
+
readonly state: string;
|
|
165
|
+
readonly duration?: string;
|
|
166
|
+
readonly url?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface GhChecksParsed {
|
|
170
|
+
readonly kind: "pr-checks";
|
|
171
|
+
readonly rows: readonly GhCheckRow[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface GhCreateParsed {
|
|
175
|
+
readonly kind: "pr-create";
|
|
176
|
+
readonly url: string;
|
|
177
|
+
readonly number?: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface GhRunListRow {
|
|
181
|
+
readonly status: string;
|
|
182
|
+
readonly conclusion?: string;
|
|
183
|
+
readonly title: string;
|
|
184
|
+
readonly workflow: string;
|
|
185
|
+
readonly branch: string;
|
|
186
|
+
readonly event: string;
|
|
187
|
+
readonly id: string;
|
|
188
|
+
readonly elapsed?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface GhRunListParsed {
|
|
192
|
+
readonly kind: "run-list";
|
|
193
|
+
readonly rows: readonly GhRunListRow[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface GhRunJob {
|
|
197
|
+
readonly state: string;
|
|
198
|
+
readonly name: string;
|
|
199
|
+
readonly count?: number;
|
|
200
|
+
readonly duration?: string;
|
|
201
|
+
readonly id?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface GhRunAnnotation {
|
|
205
|
+
readonly text: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface GhRunViewParsed {
|
|
209
|
+
readonly kind: "run-view";
|
|
210
|
+
readonly state?: string;
|
|
211
|
+
readonly branch?: string;
|
|
212
|
+
readonly workflow?: string;
|
|
213
|
+
readonly id?: string;
|
|
214
|
+
readonly trigger?: string;
|
|
215
|
+
readonly jobs: readonly GhRunJob[];
|
|
216
|
+
readonly annotations: readonly GhRunAnnotation[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface GhRunJobParsed {
|
|
220
|
+
readonly kind: "run-job";
|
|
221
|
+
readonly jobId: string;
|
|
222
|
+
readonly lines: readonly string[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type GhParsedSemantic =
|
|
226
|
+
| GhListParsed
|
|
227
|
+
| GhViewParsed
|
|
228
|
+
| GhChecksParsed
|
|
229
|
+
| GhCreateParsed
|
|
230
|
+
| GhRunListParsed
|
|
231
|
+
| GhRunViewParsed
|
|
232
|
+
| GhRunJobParsed;
|
|
233
|
+
|
|
234
|
+
// ── JSON detection ──────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
type JsonProbe = { readonly json: unknown } | { readonly notJson: true } | null;
|
|
237
|
+
|
|
238
|
+
/** Probe whether the text is `gh --json` output (starts with `{`/`[`). Returns
|
|
239
|
+
* `{json}` on a successful parse, `{notJson}` for table/rich text, or `null`
|
|
240
|
+
* when the text looks like JSON but fails to parse (hostile). */
|
|
241
|
+
function probeJson(text: string): JsonProbe {
|
|
242
|
+
const trimmed = text.trimStart();
|
|
243
|
+
const first = trimmed[0];
|
|
244
|
+
if (first !== "{" && first !== "[") return { notJson: true };
|
|
245
|
+
try {
|
|
246
|
+
return { json: JSON.parse(trimmed) };
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function asString(value: unknown): string | undefined {
|
|
253
|
+
return typeof value === "string" ? value : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function asNumber(value: unknown): number | undefined {
|
|
257
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `author` may be a bare login string or `{ login }` (the `gh --json` shape). */
|
|
261
|
+
function authorOf(value: unknown): string | undefined {
|
|
262
|
+
if (typeof value === "string") return value;
|
|
263
|
+
if (value && typeof value === "object") return asString((value as Record<string, unknown>).login);
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Join an array of `{ login }` / `{ name }` objects into a comma list. */
|
|
268
|
+
function nameList(value: unknown, field: "login" | "name" = "login"): string | undefined {
|
|
269
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
270
|
+
const names = value
|
|
271
|
+
.map((item) => (item && typeof item === "object" ? asString((item as Record<string, unknown>)[field]) : undefined))
|
|
272
|
+
.filter((name): name is string => typeof name === "string");
|
|
273
|
+
return names.length > 0 ? names.join(", ") : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── list parsers (pr list / issue list) ─────────────────────────────────────
|
|
277
|
+
// Tab-separated default output. `gh pr list` columns are
|
|
278
|
+
// NUMBER<TAB>TITLE<TAB>BRANCH<TAB>STATE<TAB>UPDATED (5); `gh issue list` is
|
|
279
|
+
// NUMBER<TAB>TITLE<TAB>STATE<TAB>UPDATED (4) and may carry labels before STATE.
|
|
280
|
+
// The state token (OPEN/CLOSED/MERGED) is located by content so both shapes
|
|
281
|
+
// parse without a fixed column count.
|
|
282
|
+
|
|
283
|
+
function parseListTable(text: string, kind: "pr-list" | "issue-list"): GhListParsed | null {
|
|
284
|
+
const rows: GhListItem[] = [];
|
|
285
|
+
for (const rawLine of text.replace(/\r/g, "").split("\n")) {
|
|
286
|
+
const line = rawLine.trimEnd();
|
|
287
|
+
if (line === "") continue;
|
|
288
|
+
const fields = line.split("\t");
|
|
289
|
+
if (fields.length < 4) return null;
|
|
290
|
+
const numberField = fields[0] ?? "";
|
|
291
|
+
if (!/^\d+$/.test(numberField)) return null;
|
|
292
|
+
const title = fields[1] ?? "";
|
|
293
|
+
let stateIndex = -1;
|
|
294
|
+
for (let i = 2; i < fields.length - 1; i++) {
|
|
295
|
+
if (GH_STATES.has((fields[i] ?? "").toUpperCase())) {
|
|
296
|
+
stateIndex = i;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (stateIndex < 0) return null;
|
|
301
|
+
const state = (fields[stateIndex] ?? "").toUpperCase();
|
|
302
|
+
const branch = kind === "pr-list" ? (fields[2] ?? "") : "";
|
|
303
|
+
rows.push({
|
|
304
|
+
number: Number(numberField),
|
|
305
|
+
title,
|
|
306
|
+
...(branch ? { branch } : {}),
|
|
307
|
+
state,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return { kind, rows };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseListJson(json: unknown, kind: "pr-list" | "issue-list"): GhListParsed | null {
|
|
314
|
+
if (!Array.isArray(json)) return null;
|
|
315
|
+
const rows: GhListItem[] = [];
|
|
316
|
+
for (const item of json) {
|
|
317
|
+
if (!item || typeof item !== "object") return null;
|
|
318
|
+
const obj = item as Record<string, unknown>;
|
|
319
|
+
const number = asNumber(obj.number);
|
|
320
|
+
if (number === undefined) return null;
|
|
321
|
+
const title = asString(obj.title);
|
|
322
|
+
if (title === undefined) return null;
|
|
323
|
+
const stateRaw = asString(obj.state);
|
|
324
|
+
if (stateRaw === undefined || !GH_STATES.has(stateRaw.toUpperCase())) return null;
|
|
325
|
+
const branch = asString(obj.headRefName);
|
|
326
|
+
rows.push({ number, title, state: stateRaw.toUpperCase(), ...(branch ? { branch } : {}) });
|
|
327
|
+
}
|
|
328
|
+
return { kind, rows };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseGhList(text: string, kind: "pr-list" | "issue-list"): GhListParsed | null {
|
|
332
|
+
const probe = probeJson(text);
|
|
333
|
+
if (probe === null) return null;
|
|
334
|
+
if ("notJson" in probe) return parseListTable(text, kind);
|
|
335
|
+
return parseListJson(probe.json, kind);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── view parsers (pr view / issue view) ─────────────────────────────────────
|
|
339
|
+
// Rich output is a block of `key:\tvalue` pairs, a `--` separator, then the
|
|
340
|
+
// markdown body. `--json` is a single object (base/head/mergeable/changedFiles
|
|
341
|
+
// only appear in JSON; the rich block carries title/state/author/labels/
|
|
342
|
+
// reviewers/number/url/additions/deletions).
|
|
343
|
+
|
|
344
|
+
const VIEW_FIELD_LINE = /^([a-zA-Z][a-zA-Z0-9-]*?):\t(.*)$/;
|
|
345
|
+
|
|
346
|
+
function parseViewRich(text: string, kind: "pr-view" | "issue-view"): GhViewParsed | null {
|
|
347
|
+
const lines = text.replace(/\r/g, "").split("\n");
|
|
348
|
+
const fields: Record<string, string> = {};
|
|
349
|
+
let bodyStart = -1;
|
|
350
|
+
for (let i = 0; i < lines.length; i++) {
|
|
351
|
+
const line = lines[i] ?? "";
|
|
352
|
+
if (line === "--") {
|
|
353
|
+
bodyStart = i + 1;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
const match = VIEW_FIELD_LINE.exec(line);
|
|
357
|
+
if (!match) {
|
|
358
|
+
if (line.trim() === "") continue; // tolerate a stray blank line
|
|
359
|
+
return null; // unrecognized line → fail closed
|
|
360
|
+
}
|
|
361
|
+
const key = match[1] ?? "";
|
|
362
|
+
const value = match[2] ?? "";
|
|
363
|
+
if (fields[key] === undefined) fields[key] = value;
|
|
364
|
+
}
|
|
365
|
+
const title = fields.title;
|
|
366
|
+
const stateRaw = fields.state;
|
|
367
|
+
if (!title || !stateRaw) return null;
|
|
368
|
+
const state = stateRaw.toUpperCase();
|
|
369
|
+
const body = bodyStart >= 0 ? lines.slice(bodyStart).join("\n").replace(/\s+$/u, "") : undefined;
|
|
370
|
+
|
|
371
|
+
const number = /^\d+$/.test(fields.number ?? "") ? Number(fields.number) : undefined;
|
|
372
|
+
const additions = /^\d+$/.test(fields.additions ?? "") ? Number(fields.additions) : undefined;
|
|
373
|
+
const deletions = /^\d+$/.test(fields.deletions ?? "") ? Number(fields.deletions) : undefined;
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
kind,
|
|
377
|
+
title,
|
|
378
|
+
state,
|
|
379
|
+
...(fields.author ? { author: fields.author } : {}),
|
|
380
|
+
...(number !== undefined ? { number } : {}),
|
|
381
|
+
...(fields.url ? { url: fields.url } : {}),
|
|
382
|
+
...(additions !== undefined ? { additions } : {}),
|
|
383
|
+
...(deletions !== undefined ? { deletions } : {}),
|
|
384
|
+
...(fields.labels ? { labels: fields.labels } : {}),
|
|
385
|
+
...(fields.reviewers ? { reviewers: fields.reviewers } : {}),
|
|
386
|
+
...(body?.trim() ? { body } : {}),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function parseViewJson(json: unknown, kind: "pr-view" | "issue-view"): GhViewParsed | null {
|
|
391
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return null;
|
|
392
|
+
const o = json as Record<string, unknown>;
|
|
393
|
+
const title = asString(o.title);
|
|
394
|
+
const stateRaw = asString(o.state);
|
|
395
|
+
if (!title || !stateRaw || !GH_STATES.has(stateRaw.toUpperCase())) return null;
|
|
396
|
+
const state = stateRaw.toUpperCase();
|
|
397
|
+
const author = authorOf(o.author);
|
|
398
|
+
const number = asNumber(o.number);
|
|
399
|
+
const url = asString(o.url);
|
|
400
|
+
const additions = asNumber(o.additions);
|
|
401
|
+
const deletions = asNumber(o.deletions);
|
|
402
|
+
const changedFiles = asNumber(o.changedFiles);
|
|
403
|
+
const baseRefName = asString(o.baseRefName);
|
|
404
|
+
const headRefName = asString(o.headRefName);
|
|
405
|
+
const mergeable = asString(o.mergeable);
|
|
406
|
+
const reviewDecision = asString(o.reviewDecision);
|
|
407
|
+
const reviewers = nameList(o.reviewRequests) ?? nameList(o.reviews);
|
|
408
|
+
const body = asString(o.body);
|
|
409
|
+
return {
|
|
410
|
+
kind,
|
|
411
|
+
title,
|
|
412
|
+
state,
|
|
413
|
+
...(author ? { author } : {}),
|
|
414
|
+
...(number !== undefined ? { number } : {}),
|
|
415
|
+
...(url ? { url } : {}),
|
|
416
|
+
...(additions !== undefined ? { additions } : {}),
|
|
417
|
+
...(deletions !== undefined ? { deletions } : {}),
|
|
418
|
+
...(changedFiles !== undefined ? { changedFiles } : {}),
|
|
419
|
+
...(baseRefName ? { baseRefName } : {}),
|
|
420
|
+
...(headRefName ? { headRefName } : {}),
|
|
421
|
+
...(mergeable ? { mergeable } : {}),
|
|
422
|
+
...(reviewDecision ? { reviewDecision } : {}),
|
|
423
|
+
...(reviewers ? { reviewers } : {}),
|
|
424
|
+
...(body?.trim() ? { body } : {}),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseGhView(text: string, kind: "pr-view" | "issue-view"): GhViewParsed | null {
|
|
429
|
+
const probe = probeJson(text);
|
|
430
|
+
if (probe === null) return null;
|
|
431
|
+
if ("notJson" in probe) return parseViewRich(text, kind);
|
|
432
|
+
return parseViewJson(probe.json, kind);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── pr checks parser ────────────────────────────────────────────────────────
|
|
436
|
+
// `NAME<TAB>STATE<TAB>DURATION<TAB>URL` (states: pass/fail/pending/skipping).
|
|
437
|
+
|
|
438
|
+
function parseChecksTable(text: string): GhChecksParsed | null {
|
|
439
|
+
const rows: GhCheckRow[] = [];
|
|
440
|
+
for (const rawLine of text.replace(/\r/g, "").split("\n")) {
|
|
441
|
+
const line = rawLine.trimEnd();
|
|
442
|
+
if (line === "") continue;
|
|
443
|
+
const fields = line.split("\t");
|
|
444
|
+
if (fields.length < 2) return null;
|
|
445
|
+
const name = fields[0] ?? "";
|
|
446
|
+
const state = (fields[1] ?? "").toLowerCase();
|
|
447
|
+
if (!GH_CHECK_STATES.has(state)) return null;
|
|
448
|
+
const duration = fields[2] !== undefined && fields[2] !== "" ? fields[2] : undefined;
|
|
449
|
+
const url = fields[3];
|
|
450
|
+
rows.push({
|
|
451
|
+
name,
|
|
452
|
+
state,
|
|
453
|
+
...(duration ? { duration } : {}),
|
|
454
|
+
...(url ? { url } : {}),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return { kind: "pr-checks", rows };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── pr create parser ────────────────────────────────────────────────────────
|
|
461
|
+
// Success prints `https://github.com/<owner>/<repo>/pull/<N>`.
|
|
462
|
+
|
|
463
|
+
const PR_CREATE_URL = /^(https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+))/;
|
|
464
|
+
|
|
465
|
+
function parseGhCreate(text: string): GhCreateParsed | null {
|
|
466
|
+
const trimmed = text.replace(/\r/g, "").trim();
|
|
467
|
+
const match = PR_CREATE_URL.exec(trimmed);
|
|
468
|
+
if (!match) return null;
|
|
469
|
+
const url = match[1] ?? "";
|
|
470
|
+
const number = match[2] !== undefined ? Number(match[2]) : undefined;
|
|
471
|
+
return { kind: "pr-create", url, ...(number !== undefined ? { number } : {}) };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── run list parser ─────────────────────────────────────────────────────────
|
|
475
|
+
// STATUS<TAB>CONCLUSION<TAB>TITLE<TAB>WORKFLOW<TAB>BRANCH<TAB>EVENT<TAB>ID<TAB>
|
|
476
|
+
// ELAPSED<TAB>AGE. JSON carries databaseId/headBranch/name/workflowName/event.
|
|
477
|
+
|
|
478
|
+
function parseRunListTable(text: string): GhRunListParsed | null {
|
|
479
|
+
const rows: GhRunListRow[] = [];
|
|
480
|
+
for (const rawLine of text.replace(/\r/g, "").split("\n")) {
|
|
481
|
+
const line = rawLine.trimEnd();
|
|
482
|
+
if (line === "") continue;
|
|
483
|
+
const fields = line.split("\t");
|
|
484
|
+
// Need at least STATUS..CONCLUSION..TITLE..WORKFLOW..BRANCH..EVENT..ID.
|
|
485
|
+
if (fields.length < 7) return null;
|
|
486
|
+
const id = fields[6] ?? "";
|
|
487
|
+
if (!/^\d+$/.test(id)) return null;
|
|
488
|
+
const status = fields[0] ?? "";
|
|
489
|
+
const conclusionField = fields[1] ?? "";
|
|
490
|
+
const conclusion = conclusionField !== "" ? conclusionField : undefined;
|
|
491
|
+
const title = fields[2] ?? "";
|
|
492
|
+
const workflow = fields[3] ?? "";
|
|
493
|
+
const branch = fields[4] ?? "";
|
|
494
|
+
const event = fields[5] ?? "";
|
|
495
|
+
const elapsed = fields[7] !== undefined && fields[7] !== "" ? fields[7] : undefined;
|
|
496
|
+
rows.push({
|
|
497
|
+
status,
|
|
498
|
+
...(conclusion ? { conclusion } : {}),
|
|
499
|
+
title,
|
|
500
|
+
workflow,
|
|
501
|
+
branch,
|
|
502
|
+
event,
|
|
503
|
+
id,
|
|
504
|
+
...(elapsed ? { elapsed } : {}),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return { kind: "run-list", rows };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseRunListJson(json: unknown): GhRunListParsed | null {
|
|
511
|
+
if (!Array.isArray(json)) return null;
|
|
512
|
+
const rows: GhRunListRow[] = [];
|
|
513
|
+
for (const item of json) {
|
|
514
|
+
if (!item || typeof item !== "object") return null;
|
|
515
|
+
const o = item as Record<string, unknown>;
|
|
516
|
+
const status = asString(o.status);
|
|
517
|
+
const id = asNumber(o.databaseId ?? o.id);
|
|
518
|
+
if (status === undefined || id === undefined) return null;
|
|
519
|
+
const workflow = asString(o.workflowName) ?? asString(o.name) ?? "";
|
|
520
|
+
const title = asString(o.displayTitle) ?? workflow;
|
|
521
|
+
rows.push({
|
|
522
|
+
status,
|
|
523
|
+
...(asString(o.conclusion) ? { conclusion: asString(o.conclusion) as string } : {}),
|
|
524
|
+
title,
|
|
525
|
+
workflow,
|
|
526
|
+
branch: asString(o.headBranch) ?? "",
|
|
527
|
+
event: asString(o.event) ?? "",
|
|
528
|
+
id: String(id),
|
|
529
|
+
...(asString(o.elapsed) ? { elapsed: o.elapsed as string } : {}),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return { kind: "run-list", rows };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function parseGhRunList(text: string): GhRunListParsed | null {
|
|
536
|
+
const probe = probeJson(text);
|
|
537
|
+
if (probe === null) return null;
|
|
538
|
+
if ("notJson" in probe) return parseRunListTable(text);
|
|
539
|
+
return parseRunListJson(probe.json);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── run view parser ─────────────────────────────────────────────────────────
|
|
543
|
+
// Rich output: a status line `✓|✗|◌ <branch> <workflow> · <id>`, an optional
|
|
544
|
+
// `Triggered via …` line, a `JOBS` block of `✓|✗|◌ <name> (N) in <dur> (ID id)`
|
|
545
|
+
// rows, an optional `ANNOTATIONS` block of `! …` rows, then trailing hint
|
|
546
|
+
// lines (`For more information…`, `View this run on GitHub:…`) which are skipped.
|
|
547
|
+
|
|
548
|
+
const RUN_VIEW_STATUS_LINE = /^([✓✗◌*])\s+(\S+)\s+(.+?)\s+·\s+(\d+)\s*$/u;
|
|
549
|
+
const RUN_VIEW_JOB_LINE = /^([✓✗◌*])\s+(.+?)\s+\((\d+)\)\s+in\s+(\S+)\s+\(ID\s+(\d+)\)\s*$/u;
|
|
550
|
+
|
|
551
|
+
function isRunViewHint(line: string): boolean {
|
|
552
|
+
return line.startsWith("For more information about the job, try:") || line.startsWith("View this run on GitHub:");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function parseGhRunView(text: string): GhRunViewParsed | null {
|
|
556
|
+
const lines = text.replace(/\r/g, "").split("\n");
|
|
557
|
+
let idx = 0;
|
|
558
|
+
while (idx < lines.length && (lines[idx] ?? "").trim() === "") idx++;
|
|
559
|
+
if (idx >= lines.length) return null;
|
|
560
|
+
|
|
561
|
+
const statusMatch = RUN_VIEW_STATUS_LINE.exec(lines[idx] ?? "");
|
|
562
|
+
if (!statusMatch) return null;
|
|
563
|
+
const state = statusMatch[1] ?? "";
|
|
564
|
+
const branch = statusMatch[2] ?? "";
|
|
565
|
+
const workflow = statusMatch[3] ?? "";
|
|
566
|
+
const id = statusMatch[4] ?? "";
|
|
567
|
+
idx++;
|
|
568
|
+
|
|
569
|
+
// Optional `Triggered via …` line.
|
|
570
|
+
let trigger: string | undefined;
|
|
571
|
+
while (idx < lines.length && (lines[idx] ?? "").trim() === "") idx++;
|
|
572
|
+
if (idx < lines.length && /^Triggered via .+/.test(lines[idx] ?? "")) {
|
|
573
|
+
trigger = lines[idx];
|
|
574
|
+
idx++;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const jobs: GhRunJob[] = [];
|
|
578
|
+
const annotations: GhRunAnnotation[] = [];
|
|
579
|
+
|
|
580
|
+
const skipBlanks = () => {
|
|
581
|
+
while (idx < lines.length && (lines[idx] ?? "").trim() === "") idx++;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
skipBlanks();
|
|
585
|
+
if ((lines[idx] ?? "") === "JOBS") {
|
|
586
|
+
idx++;
|
|
587
|
+
while (idx < lines.length) {
|
|
588
|
+
const line = lines[idx] ?? "";
|
|
589
|
+
if (line === "") {
|
|
590
|
+
idx++;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
if (line === "ANNOTATIONS" || isRunViewHint(line)) break;
|
|
594
|
+
const jobMatch = RUN_VIEW_JOB_LINE.exec(line);
|
|
595
|
+
if (!jobMatch) return null;
|
|
596
|
+
jobs.push({
|
|
597
|
+
state: jobMatch[1] ?? "",
|
|
598
|
+
name: jobMatch[2] ?? "",
|
|
599
|
+
count: Number(jobMatch[3] ?? 0),
|
|
600
|
+
duration: jobMatch[4] ?? "",
|
|
601
|
+
id: jobMatch[5] ?? "",
|
|
602
|
+
});
|
|
603
|
+
idx++;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
skipBlanks();
|
|
608
|
+
if ((lines[idx] ?? "") === "ANNOTATIONS") {
|
|
609
|
+
idx++;
|
|
610
|
+
while (idx < lines.length) {
|
|
611
|
+
const line = lines[idx] ?? "";
|
|
612
|
+
if (line === "") {
|
|
613
|
+
idx++;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (isRunViewHint(line)) break;
|
|
617
|
+
const annotationMatch = /^!\s+(.+)$/.exec(line);
|
|
618
|
+
if (annotationMatch) {
|
|
619
|
+
annotations.push({ text: annotationMatch[1] ?? "" });
|
|
620
|
+
idx++;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// Location-reference row following a `! …` message (`check (22):
|
|
624
|
+
// .github#2`) — a dim source pointer, not a new annotation.
|
|
625
|
+
const sourceMatch = /^[A-Za-z0-9_ ./()-]+\(\d+\): \S+#\d+$/.exec(line);
|
|
626
|
+
if (sourceMatch) {
|
|
627
|
+
annotations.push({ text: line });
|
|
628
|
+
idx++;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Only hint/blank lines may follow; anything else fails closed.
|
|
636
|
+
for (let i = idx; i < lines.length; i++) {
|
|
637
|
+
const line = lines[i] ?? "";
|
|
638
|
+
if (line.trim() === "") continue;
|
|
639
|
+
if (isRunViewHint(line)) continue;
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
kind: "run-view",
|
|
645
|
+
...(state ? { state } : {}),
|
|
646
|
+
...(branch ? { branch } : {}),
|
|
647
|
+
...(workflow ? { workflow } : {}),
|
|
648
|
+
...(id ? { id } : {}),
|
|
649
|
+
...(trigger ? { trigger } : {}),
|
|
650
|
+
jobs,
|
|
651
|
+
annotations,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── run job parser ──────────────────────────────────────────────────────────
|
|
656
|
+
// The job log is raw text; any output is a valid log body (the boxed result
|
|
657
|
+
// owns width-truncation and a render budget). It never fails closed.
|
|
658
|
+
|
|
659
|
+
function parseGhRunJob(text: string, jobId: string): GhRunJobParsed {
|
|
660
|
+
const body = String(text ?? "").replace(/\r/g, "");
|
|
661
|
+
const lines = body.split("\n");
|
|
662
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
663
|
+
return { kind: "run-job", jobId, lines };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Dispatch helpers (used by bash.ts) ──────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
export function parseGhOutput(cls: GhSemanticClass, output: string): GhParsedSemantic | null {
|
|
669
|
+
const text = String(output ?? "");
|
|
670
|
+
switch (cls.kind) {
|
|
671
|
+
case "pr-list":
|
|
672
|
+
return parseGhList(text, "pr-list");
|
|
673
|
+
case "issue-list":
|
|
674
|
+
return parseGhList(text, "issue-list");
|
|
675
|
+
case "pr-view":
|
|
676
|
+
return parseGhView(text, "pr-view");
|
|
677
|
+
case "issue-view":
|
|
678
|
+
return parseGhView(text, "issue-view");
|
|
679
|
+
case "pr-checks":
|
|
680
|
+
return parseChecksTable(text);
|
|
681
|
+
case "pr-create":
|
|
682
|
+
return parseGhCreate(text);
|
|
683
|
+
case "run-list":
|
|
684
|
+
return parseGhRunList(text);
|
|
685
|
+
case "run-view":
|
|
686
|
+
return parseGhRunView(text);
|
|
687
|
+
case "run-job":
|
|
688
|
+
return parseGhRunJob(text, cls.jobId);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
/** Nerd Font GitHub mark glyph used on gh card headers in Nerd Font mode. */
|
|
695
|
+
export const GH_ICON = "\u{F408}";
|
|
696
|
+
|
|
697
|
+
const GH_CARD_HEAD_LIMIT = 6;
|
|
698
|
+
const GH_BODY_PREVIEW_LINES = 8;
|
|
699
|
+
|
|
700
|
+
function ghCardHeader(theme: BoxTheme, cls: GhSemanticClass, parsed?: GhParsedSemantic): string {
|
|
701
|
+
const icon = getToolsRenderConfig().nerdFonts ? `${GH_ICON} ` : "";
|
|
702
|
+
let prefix: string;
|
|
703
|
+
switch (cls.kind) {
|
|
704
|
+
case "pr-list":
|
|
705
|
+
prefix = `${icon}PRs`;
|
|
706
|
+
break;
|
|
707
|
+
case "pr-view":
|
|
708
|
+
prefix = `${icon}PR`;
|
|
709
|
+
break;
|
|
710
|
+
case "pr-checks":
|
|
711
|
+
prefix = `${icon}PR checks`;
|
|
712
|
+
break;
|
|
713
|
+
case "pr-create":
|
|
714
|
+
prefix = `${icon}PR created`;
|
|
715
|
+
break;
|
|
716
|
+
case "issue-list":
|
|
717
|
+
prefix = `${icon}Issues`;
|
|
718
|
+
break;
|
|
719
|
+
case "issue-view":
|
|
720
|
+
prefix = `${icon}Issue`;
|
|
721
|
+
break;
|
|
722
|
+
case "run-list":
|
|
723
|
+
prefix = `${icon}Runs`;
|
|
724
|
+
break;
|
|
725
|
+
case "run-view":
|
|
726
|
+
prefix = `${icon}Run`;
|
|
727
|
+
break;
|
|
728
|
+
case "run-job":
|
|
729
|
+
prefix = `${icon}Run job`;
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
if (parsed) {
|
|
733
|
+
if (parsed.kind === "pr-view" || parsed.kind === "issue-view") {
|
|
734
|
+
if (parsed.number !== undefined) prefix += ` #${parsed.number}`;
|
|
735
|
+
prefix += ` · ${parsed.title}`;
|
|
736
|
+
} else if (parsed.kind === "pr-create") {
|
|
737
|
+
if (parsed.number !== undefined) prefix += ` #${parsed.number}`;
|
|
738
|
+
} else if (parsed.kind === "run-view") {
|
|
739
|
+
if (parsed.workflow) prefix += ` · ${parsed.workflow}`;
|
|
740
|
+
if (parsed.id) prefix += ` · ${parsed.id}`;
|
|
741
|
+
} else if (parsed.kind === "run-job") {
|
|
742
|
+
prefix += ` · ${parsed.jobId}`;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return typeof theme?.bold === "function" ? theme.bold(prefix) : prefix;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** State color for an OPEN/CLOSED/MERGED value. */
|
|
749
|
+
function ghStateColor(state: string): string {
|
|
750
|
+
if (state === "OPEN") return "accent";
|
|
751
|
+
if (state === "MERGED") return "toolDiffAdded";
|
|
752
|
+
return "dim"; // CLOSED
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Colored run glyph from a status/conclusion pair (✓ success, ✗ failure,
|
|
756
|
+
* ◌ in-progress/queued, dim for skipped/cancelled). */
|
|
757
|
+
function runGlyph(theme: BoxTheme, status: string, conclusion?: string): string {
|
|
758
|
+
if (status === "completed") {
|
|
759
|
+
if (conclusion === "success") return theme.fg("toolDiffAdded", "✓");
|
|
760
|
+
if (conclusion === "failure") return theme.fg("error", "✗");
|
|
761
|
+
return theme.fg("dim", "◌"); // cancelled / skipped / neutral
|
|
762
|
+
}
|
|
763
|
+
return theme.fg("warning", "◌"); // in_progress / queued / waiting
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Colored run glyph from a raw ✓/✗/◌ token (run-view jobs). */
|
|
767
|
+
function runStateGlyph(theme: BoxTheme, glyph: string): string {
|
|
768
|
+
if (glyph === "✓") return theme.fg("toolDiffAdded", "✓");
|
|
769
|
+
if (glyph === "✗") return theme.fg("error", "✗");
|
|
770
|
+
return theme.fg("warning", "◌");
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Check-state color (pass/fail/pending/skipping/…). */
|
|
774
|
+
function checkStateColor(state: string): string {
|
|
775
|
+
if (state === "pass") return "toolDiffAdded";
|
|
776
|
+
if (state === "fail") return "error";
|
|
777
|
+
if (state === "pending") return "warning";
|
|
778
|
+
return "dim"; // skipping / neutral / cancelled / …
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function renderMoreRow(theme: BoxTheme, unit: string, more: number, width: number): string {
|
|
782
|
+
return safeTruncateToWidth(
|
|
783
|
+
`${TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm(unit, more)}`)}`,
|
|
784
|
+
width,
|
|
785
|
+
"…",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function renderListCard(theme: BoxTheme, parsed: GhListParsed, out: string[], width: number): string[] {
|
|
790
|
+
const rows = parsed.rows;
|
|
791
|
+
const noun = parsed.kind === "pr-list" ? "PR" : "issue";
|
|
792
|
+
if (rows.length === 0) {
|
|
793
|
+
out.push(theme.fg("muted", ` no open ${pluralForm(noun, 2)}`));
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
out.push(` ${theme.fg("accent", `${rows.length} ${pluralForm(noun, rows.length)}`)}`);
|
|
797
|
+
|
|
798
|
+
const visible = rows.slice(0, GH_CARD_HEAD_LIMIT);
|
|
799
|
+
const more = rows.length - visible.length;
|
|
800
|
+
const lastIndex = visible.length - 1;
|
|
801
|
+
for (let i = 0; i < visible.length; i++) {
|
|
802
|
+
const row = visible[i];
|
|
803
|
+
if (!row) continue;
|
|
804
|
+
const branchGlyph = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
805
|
+
const color = ghStateColor(row.state);
|
|
806
|
+
const num = theme.fg(color, `#${row.number}`);
|
|
807
|
+
const title = theme.fg("toolOutput", row.title);
|
|
808
|
+
const stateSuffix = row.state !== "OPEN" ? theme.fg("dim", ` (${row.state.toLowerCase()})`) : "";
|
|
809
|
+
const branchPart = row.branch ? theme.fg("dim", ` ${row.branch}`) : "";
|
|
810
|
+
const line = `${TREE_INDENT}${dimLine(branchGlyph)} ${num} ${title}${stateSuffix}${branchPart}`;
|
|
811
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
812
|
+
}
|
|
813
|
+
if (more > 0) out.push(renderMoreRow(theme, noun, more, width));
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function renderBodyPreview(theme: BoxTheme, body: string, out: string[], width: number): string[] {
|
|
818
|
+
const bodyLines = body.replace(/\s+$/u, "").split("\n");
|
|
819
|
+
const visible = bodyLines.slice(0, GH_BODY_PREVIEW_LINES);
|
|
820
|
+
for (const line of visible) {
|
|
821
|
+
out.push(safeTruncateToWidth(` ${theme.fg("muted", line)}`, width, "…"));
|
|
822
|
+
}
|
|
823
|
+
const more = bodyLines.length - visible.length;
|
|
824
|
+
if (more > 0) {
|
|
825
|
+
out.push(safeTruncateToWidth(` ${theme.fg("dim", `… ${more} more lines · Ctrl+O`)}`, width, "…"));
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function renderViewCard(theme: BoxTheme, parsed: GhViewParsed, out: string[], width: number): string[] {
|
|
831
|
+
const stateParts = [theme.fg(ghStateColor(parsed.state), parsed.state)];
|
|
832
|
+
if (parsed.baseRefName && parsed.headRefName) {
|
|
833
|
+
stateParts.push(theme.fg("dim", "·"), theme.fg("text", `${parsed.baseRefName} → ${parsed.headRefName}`));
|
|
834
|
+
}
|
|
835
|
+
out.push(safeTruncateToWidth(` ${stateParts.join(theme.fg("dim", " "))}`, width, "…"));
|
|
836
|
+
|
|
837
|
+
const summaryParts: string[] = [];
|
|
838
|
+
const diffParts: string[] = [];
|
|
839
|
+
if (parsed.additions !== undefined && parsed.additions > 0) {
|
|
840
|
+
diffParts.push(theme.fg("toolDiffAdded", `+${parsed.additions}`));
|
|
841
|
+
}
|
|
842
|
+
if (parsed.deletions !== undefined && parsed.deletions > 0) {
|
|
843
|
+
diffParts.push(theme.fg("toolDiffRemoved", `-${parsed.deletions}`));
|
|
844
|
+
}
|
|
845
|
+
if (diffParts.length > 0) summaryParts.push(diffParts.join(" "));
|
|
846
|
+
if (parsed.changedFiles !== undefined) {
|
|
847
|
+
summaryParts.push(theme.fg("accent", `${parsed.changedFiles} ${pluralForm("file", parsed.changedFiles)}`));
|
|
848
|
+
}
|
|
849
|
+
if (summaryParts.length > 0) {
|
|
850
|
+
out.push(safeTruncateToWidth(` ${summaryParts.join(theme.fg("dim", " · "))}`, width, "…"));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (parsed.author) {
|
|
854
|
+
out.push(safeTruncateToWidth(` ${theme.fg("dim", "author")} ${theme.fg("text", parsed.author)}`, width, "…"));
|
|
855
|
+
}
|
|
856
|
+
if (parsed.reviewers) {
|
|
857
|
+
out.push(
|
|
858
|
+
safeTruncateToWidth(` ${theme.fg("dim", "reviewers")} ${theme.fg("toolOutput", parsed.reviewers)}`, width, "…"),
|
|
859
|
+
);
|
|
860
|
+
} else if (parsed.reviewDecision) {
|
|
861
|
+
out.push(
|
|
862
|
+
safeTruncateToWidth(` ${theme.fg("dim", "review")} ${theme.fg("text", parsed.reviewDecision)}`, width, "…"),
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (parsed.mergeable) {
|
|
866
|
+
out.push(
|
|
867
|
+
safeTruncateToWidth(` ${theme.fg("dim", "mergeable")} ${theme.fg("text", parsed.mergeable)}`, width, "…"),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
if (parsed.body?.trim()) renderBodyPreview(theme, parsed.body, out, width);
|
|
871
|
+
return out;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function renderChecksCard(theme: BoxTheme, parsed: GhChecksParsed, out: string[], width: number): string[] {
|
|
875
|
+
const rows = parsed.rows;
|
|
876
|
+
if (rows.length === 0) {
|
|
877
|
+
out.push(theme.fg("muted", " no checks reported"));
|
|
878
|
+
return out;
|
|
879
|
+
}
|
|
880
|
+
const visible = rows.slice(0, GH_CARD_HEAD_LIMIT);
|
|
881
|
+
const more = rows.length - visible.length;
|
|
882
|
+
const lastIndex = visible.length - 1;
|
|
883
|
+
for (let i = 0; i < visible.length; i++) {
|
|
884
|
+
const row = visible[i];
|
|
885
|
+
if (!row) continue;
|
|
886
|
+
const branchGlyph = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
887
|
+
const name = theme.fg("toolOutput", row.name);
|
|
888
|
+
const state = theme.fg(checkStateColor(row.state), row.state);
|
|
889
|
+
const duration = row.duration && row.duration !== "0" ? theme.fg("dim", ` ${row.duration}`) : "";
|
|
890
|
+
const line = `${TREE_INDENT}${dimLine(branchGlyph)} ${name} ${state}${duration}`;
|
|
891
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
892
|
+
}
|
|
893
|
+
if (more > 0) out.push(renderMoreRow(theme, "check", more, width));
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function renderCreateCard(theme: BoxTheme, parsed: GhCreateParsed, out: string[], width: number): string[] {
|
|
898
|
+
out.push(safeTruncateToWidth(` ${theme.fg("text", parsed.url)}`, width, "…"));
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function renderRunListCard(theme: BoxTheme, parsed: GhRunListParsed, out: string[], width: number): string[] {
|
|
903
|
+
const rows = parsed.rows;
|
|
904
|
+
if (rows.length === 0) {
|
|
905
|
+
out.push(theme.fg("muted", " no recent runs"));
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
908
|
+
const visible = rows.slice(0, GH_CARD_HEAD_LIMIT);
|
|
909
|
+
const more = rows.length - visible.length;
|
|
910
|
+
const lastIndex = visible.length - 1;
|
|
911
|
+
for (let i = 0; i < visible.length; i++) {
|
|
912
|
+
const row = visible[i];
|
|
913
|
+
if (!row) continue;
|
|
914
|
+
const branchGlyph = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
915
|
+
const glyph = runGlyph(theme, row.status, row.conclusion);
|
|
916
|
+
const workflow = theme.fg("text", row.workflow || row.title);
|
|
917
|
+
const branch = theme.fg("dim", ` ${row.branch}`);
|
|
918
|
+
const id = theme.fg("dim", ` ${row.id}`);
|
|
919
|
+
const line = `${TREE_INDENT}${dimLine(branchGlyph)} ${glyph} ${workflow}${branch}${id}`;
|
|
920
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
921
|
+
}
|
|
922
|
+
if (more > 0) out.push(renderMoreRow(theme, "run", more, width));
|
|
923
|
+
return out;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function renderRunViewCard(theme: BoxTheme, parsed: GhRunViewParsed, out: string[], width: number): string[] {
|
|
927
|
+
if (parsed.trigger) {
|
|
928
|
+
out.push(safeTruncateToWidth(` ${theme.fg("dim", parsed.trigger)}`, width, "…"));
|
|
929
|
+
}
|
|
930
|
+
const visibleJobs = parsed.jobs.slice(0, GH_CARD_HEAD_LIMIT);
|
|
931
|
+
const moreJobs = parsed.jobs.length - visibleJobs.length;
|
|
932
|
+
const lastJobIndex = visibleJobs.length - 1;
|
|
933
|
+
for (let i = 0; i < visibleJobs.length; i++) {
|
|
934
|
+
const job = visibleJobs[i];
|
|
935
|
+
if (!job) continue;
|
|
936
|
+
const branchGlyph = i < lastJobIndex || moreJobs > 0 || parsed.annotations.length > 0 ? "├─" : "└─";
|
|
937
|
+
const glyph = runStateGlyph(theme, job.state);
|
|
938
|
+
const name = theme.fg("toolOutput", `${job.name}${job.count !== undefined ? ` (${job.count})` : ""}`);
|
|
939
|
+
const detail = theme.fg("dim", `${job.duration ? ` ${job.duration}` : ""}${job.id ? ` · ${job.id}` : ""}`);
|
|
940
|
+
const line = `${TREE_INDENT}${dimLine(branchGlyph)} ${glyph} ${name}${detail}`;
|
|
941
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
942
|
+
}
|
|
943
|
+
if (moreJobs > 0) out.push(renderMoreRow(theme, "job", moreJobs, width));
|
|
944
|
+
for (let i = 0; i < parsed.annotations.length; i++) {
|
|
945
|
+
const annotation = parsed.annotations[i];
|
|
946
|
+
if (!annotation) continue;
|
|
947
|
+
const branchGlyph = i < parsed.annotations.length - 1 ? "├─" : "└─";
|
|
948
|
+
const line = `${TREE_INDENT}${dimLine(branchGlyph)} ${theme.fg("warning", "!")} ${theme.fg("dim", annotation.text)}`;
|
|
949
|
+
out.push(safeTruncateToWidth(line, width, "…"));
|
|
950
|
+
}
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Render the gh semantic card for one call: the header always renders (so a
|
|
956
|
+
* pending call shows a single summary line); once the result parses, counts,
|
|
957
|
+
* rows, and a body preview follow. `run-job` renders its header here while the
|
|
958
|
+
* log body lives in the boxed result. Every line is width-safe.
|
|
959
|
+
*/
|
|
960
|
+
export function renderGhCardLines(
|
|
961
|
+
theme: BoxTheme,
|
|
962
|
+
state: { readonly cls: GhSemanticClass; readonly parsed?: GhParsedSemantic },
|
|
963
|
+
width: number,
|
|
964
|
+
): string[] {
|
|
965
|
+
const safeWidth = Math.max(1, width);
|
|
966
|
+
const out: string[] = [safeTruncateToWidth(ghCardHeader(theme, state.cls, state.parsed), safeWidth, "…")];
|
|
967
|
+
const parsed = state.parsed;
|
|
968
|
+
if (!parsed) return out;
|
|
969
|
+
if (parsed.kind === "pr-list" || parsed.kind === "issue-list") renderListCard(theme, parsed, out, safeWidth);
|
|
970
|
+
else if (parsed.kind === "pr-view" || parsed.kind === "issue-view") renderViewCard(theme, parsed, out, safeWidth);
|
|
971
|
+
else if (parsed.kind === "pr-checks") renderChecksCard(theme, parsed, out, safeWidth);
|
|
972
|
+
else if (parsed.kind === "pr-create") renderCreateCard(theme, parsed, out, safeWidth);
|
|
973
|
+
else if (parsed.kind === "run-list") renderRunListCard(theme, parsed, out, safeWidth);
|
|
974
|
+
else if (parsed.kind === "run-view") renderRunViewCard(theme, parsed, out, safeWidth);
|
|
975
|
+
// run-job: header only — the log body renders in the boxed result.
|
|
976
|
+
return out.map((line) => safeTruncateToWidth(line, safeWidth, "…"));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ── Boxed run-job log result (Phase 8D) ────────────────────────────────────
|
|
980
|
+
// `gh run view --job=<id>` log output renders in a `renderBoxedToolResult`
|
|
981
|
+
// frame with a `Log · <job-id>` divider and an elapsed footer, mirroring the
|
|
982
|
+
// git diff boxed result. The call panel renders the boxless `Run job · <id>`
|
|
983
|
+
// header; the log body lives in the box. A render budget bounds very long logs
|
|
984
|
+
// (collapsed/expanded), and `renderBoxedToolResult` width-truncates each line.
|
|
985
|
+
|
|
986
|
+
const GH_RUN_JOB_BUDGET_COLLAPSED = 40;
|
|
987
|
+
const GH_RUN_JOB_BUDGET_EXPANDED = 200;
|
|
988
|
+
|
|
989
|
+
/** Build a complete boxed-log result component for a parsed `gh run view --job`. */
|
|
990
|
+
export function renderGhRunJobResult(
|
|
991
|
+
theme: BoxTheme,
|
|
992
|
+
parsed: GhRunJobParsed,
|
|
993
|
+
options: { expanded: boolean },
|
|
994
|
+
context: BoxedToolContext,
|
|
995
|
+
): Component {
|
|
996
|
+
const expanded = Boolean(options.expanded);
|
|
997
|
+
const elapsedMs = getStateElapsedMs(context.state);
|
|
998
|
+
const footerParts: string[] = [];
|
|
999
|
+
if (elapsedMs !== undefined) footerParts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
|
|
1000
|
+
const footer = footerParts.join(theme.fg("dim", " · "));
|
|
1001
|
+
const hasLog = parsed.lines.some((line) => line.trim() !== "");
|
|
1002
|
+
const budget = expanded ? GH_RUN_JOB_BUDGET_EXPANDED : GH_RUN_JOB_BUDGET_COLLAPSED;
|
|
1003
|
+
return renderBoxedToolResult(
|
|
1004
|
+
theme,
|
|
1005
|
+
() => (hasLog ? parsed.lines.map((line) => theme.fg("toolOutput", line)) : [theme.fg("muted", "No log output")]),
|
|
1006
|
+
{
|
|
1007
|
+
dividerLabel: `Log · ${parsed.jobId}`,
|
|
1008
|
+
footerLines: footer ? [footer] : [],
|
|
1009
|
+
renderLineBudget: budget,
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
1012
|
+
}
|