mergestorm 0.3.13 → 0.3.15

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.
Files changed (60) hide show
  1. package/README.md +14 -15
  2. package/dist/api.d.ts +82 -5
  3. package/dist/api.js +166 -7
  4. package/dist/client/index.d.ts +2 -2
  5. package/dist/client/index.js +2 -2
  6. package/dist/commands/branches.d.ts +6 -2
  7. package/dist/commands/branches.js +29 -14
  8. package/dist/commands/browse.d.ts +39 -0
  9. package/dist/commands/browse.js +259 -0
  10. package/dist/commands/credits.js +7 -10
  11. package/dist/commands/help.d.ts +5 -0
  12. package/dist/commands/help.js +93 -0
  13. package/dist/commands/jobs.d.ts +11 -2
  14. package/dist/commands/jobs.js +64 -15
  15. package/dist/commands/login.js +4 -3
  16. package/dist/commands/logout.js +2 -1
  17. package/dist/commands/pr.d.ts +22 -0
  18. package/dist/commands/pr.js +127 -0
  19. package/dist/commands/queue.d.ts +12 -0
  20. package/dist/commands/queue.js +160 -0
  21. package/dist/commands/registry.js +93 -18
  22. package/dist/commands/review-client.d.ts +13 -0
  23. package/dist/commands/review-client.js +110 -0
  24. package/dist/commands/review.d.ts +6 -0
  25. package/dist/commands/review.js +18 -7
  26. package/dist/commands/settings.d.ts +17 -0
  27. package/dist/commands/settings.js +89 -0
  28. package/dist/commands/skill.d.ts +20 -0
  29. package/dist/commands/skill.js +145 -0
  30. package/dist/commands/stack.d.ts +2 -0
  31. package/dist/commands/stack.js +91 -57
  32. package/dist/commands/status.d.ts +2 -0
  33. package/dist/commands/status.js +30 -15
  34. package/dist/commands/whoami.d.ts +3 -1
  35. package/dist/commands/whoami.js +21 -7
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/shell.js +39 -31
  38. package/dist/stack-dto.d.ts +82 -1
  39. package/dist/ui/ansi.d.ts +2 -0
  40. package/dist/ui/ansi.js +2 -0
  41. package/dist/ui/banner.d.ts +34 -2
  42. package/dist/ui/banner.js +163 -79
  43. package/dist/ui/findings.d.ts +2 -1
  44. package/dist/ui/findings.js +13 -7
  45. package/dist/ui/line-tabs.d.ts +27 -0
  46. package/dist/ui/line-tabs.js +170 -0
  47. package/dist/ui/logo.d.ts +10 -2
  48. package/dist/ui/logo.js +13 -9
  49. package/dist/ui/present.d.ts +10 -0
  50. package/dist/ui/present.js +13 -0
  51. package/dist/ui/prompt.d.ts +58 -1
  52. package/dist/ui/prompt.js +225 -42
  53. package/dist/ui/select.js +1 -1
  54. package/dist/ui/tabs.d.ts +125 -0
  55. package/dist/ui/tabs.js +480 -0
  56. package/dist/ui/usage.d.ts +4 -0
  57. package/dist/ui/usage.js +22 -2
  58. package/package.json +2 -1
  59. package/skill/SKILL.md +98 -0
  60. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
@@ -0,0 +1,259 @@
1
+ import { getMe, getReview, getSettings, listJobs, patchSettings, SETTINGS_WRITABLE_KEYS, } from "../api.js";
2
+ import { apiBase, keyDisplay, loadConfig, resolveApiKey } from "../config.js";
3
+ import { CommandError } from "../errors.js";
4
+ import { ansi } from "../ui/ansi.js";
5
+ import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
6
+ import { runTabsBrowser, } from "../ui/tabs.js";
7
+ import { formatDaysLeft, formatResetLabel } from "../ui/usage.js";
8
+ import { relativeWhen } from "./jobs.js";
9
+ /** The tabbed browser needs a real terminal on both ends. */
10
+ export function canBrowse() {
11
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
+ }
13
+ /** Shared job row mapper for the Usage tab and the static `credits` panel. */
14
+ export function toPanelJob(job) {
15
+ return {
16
+ job: job.job_id.slice(0, 8),
17
+ verdict: job.verdict ?? job.status ?? "-",
18
+ thread: job.thread_slug ?? "-",
19
+ credits: job.credits ? String(job.credits.standard) : "-",
20
+ when: relativeWhen(job.created_at),
21
+ };
22
+ }
23
+ /** Short chrome note — lives on the hint row, not in the Status body. */
24
+ export function statusReachabilityNote(input) {
25
+ if (input.keyInvalid)
26
+ return "key revoked";
27
+ if (!input.reachable)
28
+ return "API offline";
29
+ return "API reachable";
30
+ }
31
+ function field(label, value) {
32
+ return ` ${label.padEnd(8)}${value}`;
33
+ }
34
+ function creditsValue(input) {
35
+ if (input.keyInvalid)
36
+ return "unavailable (invalid or revoked key)";
37
+ if (!input.reachable)
38
+ return "unavailable";
39
+ if (input.limit == null && input.used == null)
40
+ return "unavailable";
41
+ if (input.limit == null)
42
+ return "unlimited";
43
+ if (input.limit === 0)
44
+ return "not included";
45
+ const used = input.used ?? 0;
46
+ const left = input.remaining != null ? input.remaining : Math.max(0, input.limit - used);
47
+ return `${used} / ${input.limit} used · ${left} left`;
48
+ }
49
+ /** Status tab body: whoami + credits from `/me`. Reachability is the footer. */
50
+ export function buildStatusTabLines(input) {
51
+ const lines = [
52
+ field("Key", `${ansi.bold(input.keyPrefix)}${input.keyName ? ` (${input.keyName})` : ""}`),
53
+ ];
54
+ if (input.plan)
55
+ lines.push(field("Plan", input.plan));
56
+ lines.push(field("API", input.apiBase));
57
+ lines.push(field("Credits", creditsValue(input)));
58
+ if (input.reachable && !input.keyInvalid && (input.resetsAt || input.limit != null)) {
59
+ const when = formatResetLabel(input.resetsAt, input.now);
60
+ const left = formatDaysLeft(input.resetsAt, input.now);
61
+ lines.push(field("Resets", left ? `${when} · ${left}` : when));
62
+ }
63
+ if (input.keyInvalid) {
64
+ lines.push(` ${ansi.dim("run")} ${ansi.brightGreen("login")} ${ansi.dim("to sign in again")}`);
65
+ }
66
+ return lines;
67
+ }
68
+ /** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
69
+ export function formatJobDetailLines(row) {
70
+ const e = toReviewJobEnvelope(row);
71
+ const lines = [];
72
+ lines.push(` Job ${e.job_id ?? "?"}`);
73
+ lines.push(` Status ${e.status}`);
74
+ if (e.verdict)
75
+ lines.push(` Verdict ${ansi.bold(e.verdict)}`);
76
+ if (e.thread) {
77
+ const n = e.thread.job_number != null ? ` · #${e.thread.job_number}` : "";
78
+ lines.push(` Thread ${e.thread.slug}${n}`);
79
+ }
80
+ if (e.router_mode)
81
+ lines.push(` Router ${e.router_mode}`);
82
+ if (e.specialists_requested.length > 0) {
83
+ lines.push(` Asked ${e.specialists_requested.join(", ")}`);
84
+ }
85
+ if (e.specialists_run.length > 0) {
86
+ lines.push(` Run ${e.specialists_run.join(", ")}`);
87
+ }
88
+ if (e.credits)
89
+ lines.push(` Credits ${e.credits.standard} standard`);
90
+ if (e.error)
91
+ lines.push(ansi.red(` Error ${e.error}`));
92
+ if (e.summary) {
93
+ lines.push("");
94
+ for (const l of e.summary.split("\n"))
95
+ lines.push(` ${l}`);
96
+ }
97
+ const inline = e.findings?.inline ?? [];
98
+ const off = e.findings?.off_diff ?? e.findings?.offDiff ?? [];
99
+ for (const c of inline) {
100
+ lines.push("");
101
+ const lane = c.specialist ? `[${c.specialist}]` : "";
102
+ const title = c.title ? ` · ${c.title}` : "";
103
+ lines.push(` [${c.severity}]${lane} ${c.path}:${c.line}${title}`);
104
+ for (const l of c.body.split("\n"))
105
+ lines.push(` ${l}`);
106
+ }
107
+ for (const c of off) {
108
+ lines.push("");
109
+ lines.push(` [${c.severity}] off-diff ${c.path ?? ""}`);
110
+ for (const l of c.body.split("\n"))
111
+ lines.push(` ${l}`);
112
+ }
113
+ if (inline.length === 0 && off.length === 0 && e.status === "completed") {
114
+ lines.push("");
115
+ lines.push(" No findings.");
116
+ }
117
+ return lines;
118
+ }
119
+ /** Human labels for the Config tab and the static `settings` printout. */
120
+ export const SETTINGS_LABELS = {
121
+ auto_review_enabled: "Auto review",
122
+ auto_patch_enabled: "Auto patch",
123
+ vortex_show_thinking_traces: "Vortex thinking traces",
124
+ repo_overview_enabled: "Repo overview",
125
+ review_unit_land_prs_enabled: "Review Unit land PRs",
126
+ cyclone_review_unit_land_prs_enabled: "Cyclone Review Unit land PRs",
127
+ vortex_seam_specialist_enabled: "Vortex seam specialist",
128
+ };
129
+ /** Config rows in a stable order: writable toggles, then connected flags. */
130
+ export function buildConfigRows(settings) {
131
+ return [
132
+ ...SETTINGS_WRITABLE_KEYS.map((key) => ({
133
+ key,
134
+ label: SETTINGS_LABELS[key],
135
+ value: settings[key] === true,
136
+ writable: true,
137
+ })),
138
+ {
139
+ key: "cyclone_connected",
140
+ label: "Cyclone",
141
+ value: settings.cyclone_connected === true,
142
+ writable: false,
143
+ },
144
+ {
145
+ key: "github_connected",
146
+ label: "GitHub",
147
+ value: settings.github_connected === true,
148
+ writable: false,
149
+ },
150
+ ];
151
+ }
152
+ function toTabsJob(job) {
153
+ return {
154
+ id: job.job_id,
155
+ cells: {
156
+ job: job.job_id.slice(0, 8),
157
+ status: job.status,
158
+ verdict: job.verdict ?? "-",
159
+ thread: (job.thread_slug ?? "-").slice(0, 24),
160
+ when: relativeWhen(job.created_at),
161
+ },
162
+ };
163
+ }
164
+ /**
165
+ * Data + entry for the tabbed Status / Usage / Jobs / Config browser (F-9).
166
+ * Degrades honestly: a null `/me` marks the API unreachable and the Usage
167
+ * tab says so, and a failed settings GET leaves the Config tab reporting
168
+ * itself unavailable instead of drawing toggles we do not have.
169
+ */
170
+ export async function openTabsBrowser(initial) {
171
+ const cfg = await loadConfig();
172
+ const key = resolveApiKey(cfg);
173
+ if (!key) {
174
+ throw new CommandError("Not logged in. Run `mergestorm login`.");
175
+ }
176
+ let me = null;
177
+ let keyInvalid = false;
178
+ try {
179
+ me = await getMe(cfg, { timeoutMs: 8_000 });
180
+ }
181
+ catch (err) {
182
+ if (err instanceof CommandError) {
183
+ keyInvalid = true;
184
+ }
185
+ else {
186
+ throw err;
187
+ }
188
+ }
189
+ let jobs = [];
190
+ let jobsError = null;
191
+ try {
192
+ jobs = await listJobs(10, cfg, { timeoutMs: 8_000 });
193
+ }
194
+ catch (err) {
195
+ jobsError = err instanceof Error ? err.message : String(err);
196
+ }
197
+ // Bearer /api/v1/settings for the Config tab. A revoked key already threw
198
+ // above; anything else degrades to an honest "unavailable" body.
199
+ let settings = null;
200
+ if (!keyInvalid) {
201
+ try {
202
+ settings = await getSettings(cfg, { timeoutMs: 8_000 });
203
+ }
204
+ catch {
205
+ settings = null;
206
+ }
207
+ }
208
+ const data = {
209
+ statusLines: buildStatusTabLines({
210
+ keyPrefix: me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…",
211
+ keyName: me?.key.name ?? null,
212
+ plan: me ? (me.plan_label_key ?? me.plan_key) : null,
213
+ apiBase: apiBase(cfg),
214
+ reachable: me !== null,
215
+ keyInvalid,
216
+ used: me?.usage.standard.used,
217
+ limit: me?.usage.standard.limit,
218
+ remaining: me?.usage.standard.remaining,
219
+ resetsAt: me?.resets_at,
220
+ }),
221
+ footerNote: statusReachabilityNote({ reachable: me !== null, keyInvalid }),
222
+ usage: me
223
+ ? {
224
+ keyPrefix: me.key.prefix,
225
+ plan: me.plan_label_key ?? me.plan_key,
226
+ used: me.usage.standard.used,
227
+ limit: me.usage.standard.limit,
228
+ resetsAt: me.resets_at,
229
+ jobs: jobs.slice(0, 5).map(toPanelJob),
230
+ jobsError: jobsError ?? undefined,
231
+ }
232
+ : null,
233
+ ...(keyInvalid
234
+ ? { usageEmptyText: "Usage unavailable (invalid or revoked key)." }
235
+ : {}),
236
+ jobs: jobs.map(toTabsJob),
237
+ ...(jobsError ? { jobsEmptyText: `Jobs unavailable: ${jobsError}` } : {}),
238
+ config: settings ? buildConfigRows(settings) : null,
239
+ ...(keyInvalid
240
+ ? { configEmptyText: "Settings unavailable (invalid or revoked key)." }
241
+ : {}),
242
+ color: ansi.enabled,
243
+ };
244
+ await runTabsBrowser({
245
+ initial,
246
+ data,
247
+ toggleSetting: async (key, value) => {
248
+ const next = await patchSettings({ [key]: value }, cfg, { timeoutMs: 8_000 });
249
+ return buildConfigRows(next);
250
+ },
251
+ loadJobDetail: async (jobId) => {
252
+ const body = await getReview(jobId, cfg, { timeoutMs: 8_000 });
253
+ const row = body && typeof body === "object"
254
+ ? { ...body, job_id: body.job_id ?? jobId }
255
+ : { job_id: jobId, status: "failed", error: "Invalid review response" };
256
+ return formatJobDetailLines(row);
257
+ },
258
+ });
259
+ }
@@ -3,18 +3,15 @@ import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { ansi } from "../ui/ansi.js";
5
5
  import { formatUsagePanel } from "../ui/usage.js";
6
- import { relativeWhen } from "./jobs.js";
7
- function toPanelJob(job) {
8
- return {
9
- job: job.job_id.slice(0, 8),
10
- verdict: job.verdict ?? job.status ?? "-",
11
- thread: job.thread_slug ?? "-",
12
- credits: job.credits ? String(job.credits.standard) : "-",
13
- when: relativeWhen(job.created_at),
14
- };
15
- }
6
+ import { canBrowse, openTabsBrowser, toPanelJob } from "./browse.js";
16
7
  export async function cmdCredits(args) {
17
8
  const asJson = args.includes("--json");
9
+ // TTY: the tabbed Status / Usage / Jobs browser, opened on Usage.
10
+ // `--json` and pipes keep the machine/static output exactly as before.
11
+ if (!asJson && canBrowse()) {
12
+ await openTabsBrowser("usage");
13
+ return;
14
+ }
18
15
  const cfg = await loadConfig();
19
16
  const me = await getMe(cfg);
20
17
  if (!me) {
@@ -0,0 +1,5 @@
1
+ import { type LineTab } from "../ui/line-tabs.js";
2
+ /** Tabbed `/help` pages. Short rows only — no paragraphs. */
3
+ export declare function buildHelpTabs(): LineTab[];
4
+ export declare function helpTabIndex(id: string): number;
5
+ export declare function openHelpBrowser(initial?: string): Promise<void>;
@@ -0,0 +1,93 @@
1
+ import { ansi } from "../ui/ansi.js";
2
+ import { runLineTabsBrowser } from "../ui/line-tabs.js";
3
+ import { canBrowse } from "./browse.js";
4
+ const cmd = (name, blurb) => ` ${ansi.brightGreen(name.padEnd(26))}${blurb}`;
5
+ const rule = (label) => ` ${ansi.bold(label)}`;
6
+ /** Tabbed `/help` pages. Short rows only — no paragraphs. */
7
+ export function buildHelpTabs() {
8
+ return [
9
+ {
10
+ id: "start",
11
+ label: "Start",
12
+ lines: [
13
+ rule("What this is"),
14
+ " mg is the Mergestorm terminal — local reviews + stacked PRs.",
15
+ " Same account as mergestorm.ai. The dashboard is the other half.",
16
+ "",
17
+ rule("The loop"),
18
+ cmd("review", "send a diff → job on the API"),
19
+ cmd("usage", "credits, jobs, who you are"),
20
+ cmd("stack create → submit", "author a stack, then open PRs"),
21
+ "",
22
+ rule("Now"),
23
+ cmd("/", "slash menu at the prompt"),
24
+ cmd("login", "if the header says not logged in"),
25
+ ],
26
+ },
27
+ {
28
+ id: "commands",
29
+ label: "Commands",
30
+ lines: [
31
+ rule("Every day"),
32
+ cmd("review [base] [head]", "diff vs origin/HEAD, main, or master"),
33
+ cmd("usage", "Status / Usage / Jobs tabs"),
34
+ cmd("status <job>", "one job, same as the Jobs tab"),
35
+ cmd("jobs", "recent reviews"),
36
+ "",
37
+ rule("Around a branch"),
38
+ cmd("branches", "pick a recently reviewed branch"),
39
+ cmd("chain [slug]", "timeline for that branch"),
40
+ cmd("thread <slug>", "jobs in a review thread"),
41
+ "",
42
+ rule("Account"),
43
+ cmd("login", "browser sign-in (or login --key)"),
44
+ cmd("whoami", "key · plan · API"),
45
+ cmd("settings", "automation toggles · Config tab"),
46
+ cmd("logout", "forget the stored key"),
47
+ cmd("clear / exit", "reprint home · leave the shell"),
48
+ ],
49
+ },
50
+ {
51
+ id: "stacks",
52
+ label: "Stacks",
53
+ lines: [
54
+ rule("New mg stack"),
55
+ cmd("stack create", "new layer on this branch"),
56
+ " commit your work",
57
+ cmd("stack submit", "push + open PRs from tip commits"),
58
+ "",
59
+ rule("Single stack (from trunk)"),
60
+ cmd("stack create --onto main", "fresh stack, not on an existing one"),
61
+ cmd("stack list", "what's registered for this repo"),
62
+ cmd("queue [add <stack-id>]", "list entries · queue verified landing"),
63
+ cmd("stack land <id>", "land now · skips queue verification"),
64
+ "",
65
+ rule("Existing PR stack"),
66
+ cmd("stack adopt org/repo#12", "import an open GitHub chain"),
67
+ cmd("stack restack <id>", "rewrite descendants after a change"),
68
+ "",
69
+ rule("Notes"),
70
+ " Happy path: create → submit → restack → land.",
71
+ " Adopt is import-only. Growing a submitted stack needs --extend.",
72
+ ],
73
+ },
74
+ ];
75
+ }
76
+ export function helpTabIndex(id) {
77
+ const tabs = buildHelpTabs();
78
+ const i = tabs.findIndex((t) => t.id === id);
79
+ return i >= 0 ? i : 0;
80
+ }
81
+ export async function openHelpBrowser(initial) {
82
+ const tabs = buildHelpTabs();
83
+ if (!canBrowse()) {
84
+ for (const tab of tabs) {
85
+ console.log(` ${tab.label}`);
86
+ for (const line of tab.lines)
87
+ console.log(line);
88
+ console.log("");
89
+ }
90
+ return;
91
+ }
92
+ await runLineTabsBrowser({ tabs, initial: initial ? helpTabIndex(initial) : 0 });
93
+ }
@@ -1,6 +1,15 @@
1
1
  import { type ThreadDetail } from "../api.js";
2
2
  export declare function relativeWhen(iso: string): string;
3
- export declare function cmdJobs(args: string[]): Promise<void>;
3
+ export declare function cmdJobs(args: string[], opts?: {
4
+ mode?: "oneshot" | "shell";
5
+ }): Promise<void>;
4
6
  /** Numbered chain timeline for a thread (branch/PR meta when present). */
5
7
  export declare function formatThreadTimeline(data: ThreadDetail): string[];
6
- export declare function cmdThread(slug: string, args?: string[]): Promise<void>;
8
+ /** Empty / missing chain — shown in the TTY panel instead of a wiped error. */
9
+ export declare function buildChainHelpLines(opts: {
10
+ slug?: string;
11
+ reason: "missing" | "empty" | "no-repo";
12
+ }): string[];
13
+ export declare function cmdThread(slug: string, args?: string[], opts?: {
14
+ mode?: "oneshot" | "shell";
15
+ }): Promise<void>;
@@ -2,6 +2,8 @@ import { apiFetch, listJobs } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { ansi } from "../ui/ansi.js";
5
+ import { present } from "../ui/present.js";
6
+ import { showLinePanel } from "../ui/line-tabs.js";
5
7
  export function relativeWhen(iso) {
6
8
  const t = new Date(iso).getTime();
7
9
  if (Number.isNaN(t))
@@ -18,18 +20,28 @@ export function relativeWhen(iso) {
18
20
  const days = Math.floor(hr / 24);
19
21
  return `${days}d ago`;
20
22
  }
21
- export async function cmdJobs(args) {
23
+ export async function cmdJobs(args, opts = {}) {
22
24
  const asJson = args.includes("--json");
23
25
  const nArg = args.find((a) => /^\d+$/.test(a));
24
26
  const limit = nArg ? Number.parseInt(nArg, 10) : 10;
25
27
  const cfg = await loadConfig();
26
28
  const items = await listJobs(limit, cfg);
27
29
  if (asJson) {
28
- console.log(JSON.stringify({ items }, null, 2));
30
+ const text = JSON.stringify({ items }, null, 2);
31
+ if (opts.mode === "shell") {
32
+ await present("Jobs", text.split("\n"));
33
+ return;
34
+ }
35
+ console.log(text);
29
36
  return;
30
37
  }
31
38
  if (items.length === 0) {
32
- console.log(ansi.dim(" No review jobs yet. Run `review` to submit one."));
39
+ await present("Jobs", [
40
+ ` ${ansi.bold("No review jobs yet")}`,
41
+ "",
42
+ ` ${ansi.brightGreen("review")} submit a diff`,
43
+ ` ${ansi.brightGreen("usage")} Status / Usage / Jobs tabs`,
44
+ ]);
33
45
  return;
34
46
  }
35
47
  const header = ["JOB", "STATUS", "VERDICT", "THREAD", "WHEN"];
@@ -42,10 +54,11 @@ export async function cmdJobs(args) {
42
54
  ]);
43
55
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]?.length ?? 0)));
44
56
  const fmt = (cols) => " " + cols.map((c, i) => c.padEnd(widths[i])).join(" ");
45
- console.log(ansi.dim(fmt(header)));
46
- for (const r of rows)
47
- console.log(fmt(r));
48
- console.log(ansi.dim(" branches: `branches` · full history: https://mergestorm.ai/settings#api"));
57
+ await present("Jobs", [
58
+ ansi.dim(fmt(header)),
59
+ ...rows.map((r) => fmt(r)),
60
+ ansi.dim(" branches: `branches` · full history: https://mergestorm.ai/settings#api"),
61
+ ]);
49
62
  }
50
63
  /** Numbered chain timeline for a thread (branch/PR meta when present). */
51
64
  export function formatThreadTimeline(data) {
@@ -68,7 +81,8 @@ export function formatThreadTimeline(data) {
68
81
  return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
69
82
  });
70
83
  if (jobs.length === 0) {
71
- lines.push(ansi.dim(" (no jobs in this chain yet)"));
84
+ lines.push("");
85
+ lines.push(...buildChainHelpLines({ slug: data.slug, reason: "empty" }));
72
86
  return lines;
73
87
  }
74
88
  for (const j of jobs) {
@@ -77,22 +91,58 @@ export function formatThreadTimeline(data) {
77
91
  }
78
92
  return lines;
79
93
  }
80
- export async function cmdThread(slug, args = []) {
94
+ const chainCmd = (name, blurb) => ` ${ansi.brightGreen(name.padEnd(22))}${blurb}`;
95
+ /** Empty / missing chain — shown in the TTY panel instead of a wiped error. */
96
+ export function buildChainHelpLines(opts) {
97
+ const title = opts.reason === "empty"
98
+ ? "No reviews on this chain yet"
99
+ : opts.reason === "no-repo"
100
+ ? "No git repo here"
101
+ : "No chain for this branch";
102
+ const lines = [` ${ansi.bold(title)}`];
103
+ if (opts.slug)
104
+ lines.push(ansi.dim(` Looked for ${opts.slug}`));
105
+ lines.push("");
106
+ lines.push(` ${ansi.bold("What this is")}`);
107
+ lines.push(" A chain is the review timeline for one branch.");
108
+ lines.push("");
109
+ lines.push(` ${ansi.bold("Do this")}`);
110
+ if (opts.reason === "no-repo") {
111
+ lines.push(chainCmd("cd <repo>", "then run chain again"));
112
+ lines.push(chainCmd("chain <slug>", "open a known thread"));
113
+ }
114
+ else {
115
+ lines.push(chainCmd("review", "submit a diff on this branch"));
116
+ lines.push(chainCmd("branches", "pick a branch that already has reviews"));
117
+ lines.push(chainCmd("chain <slug>", "open a known thread slug"));
118
+ }
119
+ return lines;
120
+ }
121
+ export async function cmdThread(slug, args = [], opts = {}) {
81
122
  const asJson = args.includes("--json");
82
123
  const cfg = await loadConfig();
83
124
  const { status, body } = await apiFetch(cfg, `/api/v1/threads/${encodeURIComponent(slug)}`);
84
125
  if (status === 404) {
85
- throw new CommandError(`Thread not found: ${slug}`);
126
+ if (asJson || !process.stdin.isTTY || !process.stdout.isTTY) {
127
+ throw new CommandError(`Thread not found: ${slug}`);
128
+ }
129
+ await showLinePanel("Chain", buildChainHelpLines({ slug, reason: "missing" }));
130
+ return;
86
131
  }
87
132
  if (status !== 200) {
88
133
  throw new CommandError(`Failed to load thread (HTTP ${status}): ${JSON.stringify(body)}`);
89
134
  }
90
135
  if (asJson) {
91
- console.log(JSON.stringify(body, null, 2));
136
+ const text = JSON.stringify(body, null, 2);
137
+ if (opts.mode === "shell") {
138
+ await present("Thread", text.split("\n"));
139
+ return;
140
+ }
141
+ console.log(text);
92
142
  return;
93
143
  }
94
144
  const data = body;
95
- for (const line of formatThreadTimeline({
145
+ const lines = formatThreadTimeline({
96
146
  ...data,
97
147
  branch: data.branch ?? null,
98
148
  owner: data.owner ?? null,
@@ -101,7 +151,6 @@ export async function cmdThread(slug, args = []) {
101
151
  status: data.status ?? "active",
102
152
  pr_linked_at: data.pr_linked_at ?? null,
103
153
  jobs: data.jobs ?? [],
104
- })) {
105
- console.log(line);
106
- }
154
+ });
155
+ await showLinePanel("Chain", lines);
107
156
  }
@@ -4,6 +4,7 @@ import { apiBase, configPath, loadConfig, saveConfig } from "../config.js";
4
4
  import { devicePost, getMe } from "../api.js";
5
5
  import { CommandError } from "../errors.js";
6
6
  import { ansi } from "../ui/ansi.js";
7
+ import { present } from "../ui/present.js";
7
8
  import { readSecretLine } from "../ui/secret-input.js";
8
9
  function openBrowser(url) {
9
10
  const os = platform();
@@ -49,7 +50,7 @@ async function loginWithKey() {
49
50
  const me = await validateApiKey(cfg);
50
51
  await saveConfig(cfg);
51
52
  const prefix = me.key?.prefix?.trim() || "msk_live_…";
52
- console.log(`Verified ${prefix}… · saved to ${configPath()}`);
53
+ await present("Login", [` Verified ${prefix}… · saved to ${configPath()}`]);
53
54
  }
54
55
  export async function cmdLogin(args) {
55
56
  if (args.includes("--key")) {
@@ -89,7 +90,7 @@ export async function cmdLogin(args) {
89
90
  const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in, } = start.body;
90
91
  const openUrl = verification_uri_complete || verification_uri;
91
92
  console.log("To sign in, open this URL and confirm the code:\n");
92
- console.log(` ${ansi.green(openUrl)}`);
93
+ console.log(` ${ansi.brightGreen(openUrl)}`);
93
94
  console.log(`\n Code: ${ansi.bold(user_code)}\n`);
94
95
  openBrowser(openUrl);
95
96
  console.log("Waiting for approval…");
@@ -108,7 +109,7 @@ export async function cmdLogin(args) {
108
109
  const next = await loadConfig();
109
110
  next.apiKey = poll.body.api_key;
110
111
  await saveConfig(next);
111
- console.log(`\nLogged in. Key saved to ${configPath()}`);
112
+ await present("Login", [` Logged in. Key saved to ${configPath()}`]);
112
113
  return;
113
114
  }
114
115
  const err = poll.body?.error;
@@ -1,9 +1,10 @@
1
1
  import { clearConfig } from "../config.js";
2
2
  import { CommandError } from "../errors.js";
3
+ import { present } from "../ui/present.js";
3
4
  export async function cmdLogout() {
4
5
  try {
5
6
  await clearConfig();
6
- console.log("Logged out. Removed stored API key.");
7
+ await present("Logout", [" Logged out. Removed stored API key."]);
7
8
  }
8
9
  catch (err) {
9
10
  throw new CommandError(`Could not remove config: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,22 @@
1
+ import { type PollPrVortexReviewOptions } from "./review-client.js";
2
+ export type ParsedPrArgs = {
3
+ owner: string;
4
+ repo: string;
5
+ prNumber: number;
6
+ format: "json" | "pretty";
7
+ wait: boolean;
8
+ afterSha?: string;
9
+ timeoutMs: number;
10
+ };
11
+ export type PrOptions = {
12
+ defaultFormat?: "json" | "pretty";
13
+ signal?: AbortSignal;
14
+ pollIntervalMs?: number;
15
+ /** Test seams for deterministic polling. */
16
+ poll?: Pick<PollPrVortexReviewOptions, "sleep" | "now" | "random">;
17
+ mode?: "oneshot" | "shell";
18
+ };
19
+ export declare function parsePrArgs(args: string[], defaults?: {
20
+ format: "json" | "pretty";
21
+ }): ParsedPrArgs;
22
+ export declare function cmdPr(args: string[], opts?: PrOptions): Promise<void>;