pi-redline 0.1.0 → 0.3.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/extensions/session-diff.ts +597 -2
- package/package.json +6 -2
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
visibleWidth,
|
|
27
27
|
wrapTextWithAnsi,
|
|
28
28
|
} from "@earendil-works/pi-tui";
|
|
29
|
+
import { completeSimple } from "@earendil-works/pi-ai";
|
|
29
30
|
import { spawnSync } from "node:child_process";
|
|
30
31
|
import { existsSync, statSync } from "node:fs";
|
|
31
32
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
@@ -193,6 +194,34 @@ function padTo(width: number, s: string): string {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
// ─── Git ─────────────────────────────────────────────────────────────────────
|
|
197
|
+
// Build a fake "diff" payload explaining that the file has no working-tree
|
|
198
|
+
// changes vs HEAD, and listing the most recent commits touching it. Lines
|
|
199
|
+
// without a leading space/+/-/@/diff prefix are classified by parseDiff as
|
|
200
|
+
// rawHeader rows, which the renderer paints in the muted overlay0 color and
|
|
201
|
+
// excludes from select-mode navigation — exactly the behavior we want here.
|
|
202
|
+
function synthesizeCommittedNotice(repoRoot: string, rel: string): string {
|
|
203
|
+
const log = spawnSync(
|
|
204
|
+
"git",
|
|
205
|
+
["-C", repoRoot, "log", "--oneline", "--no-color", "-n", "10", "--", rel],
|
|
206
|
+
{ encoding: "utf8" },
|
|
207
|
+
);
|
|
208
|
+
const entries = (log.stdout ?? "").split("\n").filter((l) => l.length > 0);
|
|
209
|
+
const lines: string[] = [
|
|
210
|
+
`diff --git a/${rel} b/${rel}`,
|
|
211
|
+
"• No uncommitted changes vs HEAD.",
|
|
212
|
+
"•",
|
|
213
|
+
];
|
|
214
|
+
if (entries.length > 0) {
|
|
215
|
+
lines.push("• Commits touching this file (newest first):");
|
|
216
|
+
for (const e of entries) lines.push(`• ${e}`);
|
|
217
|
+
lines.push("•");
|
|
218
|
+
lines.push("• Tip: `git show <sha>` to inspect any commit.");
|
|
219
|
+
} else {
|
|
220
|
+
lines.push("• No commits touch this file either — content matches HEAD.");
|
|
221
|
+
}
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
196
225
|
function gitDiff(repoRoot: string | null, abs: string): string {
|
|
197
226
|
const UFLAG = "--unified=999999";
|
|
198
227
|
if (!repoRoot) {
|
|
@@ -213,7 +242,13 @@ function gitDiff(repoRoot: string | null, abs: string): string {
|
|
|
213
242
|
["-C", repoRoot, "diff", "HEAD", "--no-color", UFLAG, "--", rel],
|
|
214
243
|
{ encoding: "utf8" },
|
|
215
244
|
);
|
|
216
|
-
|
|
245
|
+
const raw = r.stdout ?? "";
|
|
246
|
+
if (raw.trim()) return raw;
|
|
247
|
+
// No uncommitted changes vs HEAD — most often this means the edits got
|
|
248
|
+
// committed during the session. Surface the recent history of this file
|
|
249
|
+
// instead of a blank pane so the user understands *why* there is nothing
|
|
250
|
+
// to redline.
|
|
251
|
+
return synthesizeCommittedNotice(repoRoot, rel);
|
|
217
252
|
}
|
|
218
253
|
if (!existsSync(abs)) return "";
|
|
219
254
|
const r = spawnSync(
|
|
@@ -315,6 +350,493 @@ type Row =
|
|
|
315
350
|
| { kind: "repoMeta"; repo: string | null; tone: "branch" | "path"; text: string }
|
|
316
351
|
| { kind: "file"; repo: string | null; abs: string };
|
|
317
352
|
|
|
353
|
+
// ─── Ship mode: commit + push + open PR ─────────────────────────────────────
|
|
354
|
+
//
|
|
355
|
+
// Press `P` after you're happy with the diff to walk through committing the
|
|
356
|
+
// session-touched files, pushing the branch, and opening a PR. Commit message
|
|
357
|
+
// and PR description are drafted with silent LLM round-trips (`completeSimple`)
|
|
358
|
+
// so they don't pollute the chat history.
|
|
359
|
+
|
|
360
|
+
function runGit(repo: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
361
|
+
const r = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8" });
|
|
362
|
+
return {
|
|
363
|
+
ok: r.status === 0,
|
|
364
|
+
stdout: (r.stdout ?? "").trim(),
|
|
365
|
+
stderr: (r.stderr ?? "").trim(),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function gitCurrentBranch(repo: string): string {
|
|
370
|
+
const r = runGit(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
371
|
+
return r.ok ? r.stdout : "";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function gitDefaultBranch(repo: string): string {
|
|
375
|
+
// origin/HEAD → origin/main (or whatever the upstream default is).
|
|
376
|
+
const r = runGit(repo, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
377
|
+
if (r.ok && r.stdout.startsWith("origin/")) return r.stdout.slice("origin/".length);
|
|
378
|
+
// Fall back to common defaults if origin/HEAD is unset.
|
|
379
|
+
for (const guess of ["main", "master", "develop"]) {
|
|
380
|
+
if (runGit(repo, ["rev-parse", "--verify", `refs/heads/${guess}`]).ok) return guess;
|
|
381
|
+
}
|
|
382
|
+
return "main";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function gitRemoteUrl(repo: string): string {
|
|
386
|
+
const r = runGit(repo, ["config", "--get", "remote.origin.url"]);
|
|
387
|
+
return r.ok ? r.stdout : "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function gitRepoSlug(repo: string): string {
|
|
391
|
+
// Parse owner/name out of a remote URL. Handles both git@ and https variants.
|
|
392
|
+
const url = gitRemoteUrl(repo);
|
|
393
|
+
if (!url) return "";
|
|
394
|
+
const m = url.match(/[:/]([^:/]+)\/([^/]+?)(?:\.git)?$/);
|
|
395
|
+
return m ? `${m[1]}/${m[2]}` : "";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Stage only the abs paths we know were touched this session, never `git add .`. */
|
|
399
|
+
function gitAddPaths(repo: string, absPaths: string[]): { ok: boolean; err: string } {
|
|
400
|
+
if (absPaths.length === 0) return { ok: true, err: "" };
|
|
401
|
+
const rels = absPaths
|
|
402
|
+
.map((p) => relative(repo, p))
|
|
403
|
+
.filter((r) => r && !r.startsWith(".."));
|
|
404
|
+
if (rels.length === 0) return { ok: true, err: "" };
|
|
405
|
+
const r = runGit(repo, ["add", "--", ...rels]);
|
|
406
|
+
return { ok: r.ok, err: r.stderr };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function gitHasStagedChanges(repo: string): boolean {
|
|
410
|
+
// `git diff --cached --quiet` exits 1 when there ARE staged changes.
|
|
411
|
+
const r = spawnSync("git", ["-C", repo, "diff", "--cached", "--quiet"], { encoding: "utf8" });
|
|
412
|
+
return r.status === 1;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function gitCommit(repo: string, subject: string, body: string): { ok: boolean; err: string } {
|
|
416
|
+
const args = ["commit", "--no-verify", "-m", subject];
|
|
417
|
+
if (body.trim()) args.push("-m", body);
|
|
418
|
+
const r = runGit(repo, args);
|
|
419
|
+
return { ok: r.ok, err: r.stderr || r.stdout };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function gitPush(repo: string, branch: string): { ok: boolean; err: string } {
|
|
423
|
+
const r = spawnSync(
|
|
424
|
+
"git",
|
|
425
|
+
["-C", repo, "push", "--set-upstream", "origin", branch],
|
|
426
|
+
{ encoding: "utf8", env: { ...process.env, GH_TOKEN: "", GITHUB_TOKEN: "" } },
|
|
427
|
+
);
|
|
428
|
+
return {
|
|
429
|
+
ok: r.status === 0,
|
|
430
|
+
err: (r.stderr ?? "").trim() || (r.stdout ?? "").trim(),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function gitCheckoutNewBranch(repo: string, branch: string): { ok: boolean; err: string } {
|
|
435
|
+
const r = runGit(repo, ["checkout", "-b", branch]);
|
|
436
|
+
return { ok: r.ok, err: r.stderr };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function gitStagedDiff(repo: string): string {
|
|
440
|
+
const r = runGit(repo, ["diff", "--cached", "--no-color", "--unified=3"]);
|
|
441
|
+
return r.stdout;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function ghCreatePr(
|
|
445
|
+
repo: string,
|
|
446
|
+
title: string,
|
|
447
|
+
body: string,
|
|
448
|
+
baseBranch: string,
|
|
449
|
+
): { ok: boolean; url: string; err: string } {
|
|
450
|
+
const r = spawnSync(
|
|
451
|
+
"gh",
|
|
452
|
+
["pr", "create", "--title", title, "--body", body, "--base", baseBranch],
|
|
453
|
+
{ cwd: repo, encoding: "utf8", env: { ...process.env, GH_TOKEN: "", GITHUB_TOKEN: "" } },
|
|
454
|
+
);
|
|
455
|
+
const out = (r.stdout ?? "").trim();
|
|
456
|
+
const err = (r.stderr ?? "").trim();
|
|
457
|
+
if (r.status !== 0) return { ok: false, url: "", err: err || out || `gh exited ${r.status}` };
|
|
458
|
+
// gh prints the PR URL on stdout.
|
|
459
|
+
const urlMatch = out.match(/https?:\/\/\S+/);
|
|
460
|
+
return { ok: true, url: urlMatch ? urlMatch[0] : out, err: "" };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function slugify(s: string): string {
|
|
464
|
+
return s
|
|
465
|
+
.toLowerCase()
|
|
466
|
+
.replace(/^(feat|fix|chore|docs|refactor|test|perf|build|ci|style|revert)(\([^)]*\))?:\s*/, "")
|
|
467
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
468
|
+
.replace(/^-+|-+$/g, "")
|
|
469
|
+
.slice(0, 40);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function silentComplete(
|
|
473
|
+
ctx: ExtensionContext,
|
|
474
|
+
systemPrompt: string,
|
|
475
|
+
userPrompt: string,
|
|
476
|
+
): Promise<string> {
|
|
477
|
+
const model = ctx.model;
|
|
478
|
+
if (!model) throw new Error("No model configured (ctx.model is undefined).");
|
|
479
|
+
const result = await completeSimple(model as any, {
|
|
480
|
+
systemPrompt,
|
|
481
|
+
messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
|
|
482
|
+
});
|
|
483
|
+
const text = result.content
|
|
484
|
+
.filter((c: any) => c.type === "text")
|
|
485
|
+
.map((c: any) => c.text as string)
|
|
486
|
+
.join("")
|
|
487
|
+
.trim();
|
|
488
|
+
if (!text) throw new Error("LLM returned an empty response.");
|
|
489
|
+
return text;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function parseCommitDraft(raw: string): { subject: string; body: string } {
|
|
493
|
+
// LLM sometimes wraps in code fences; strip them.
|
|
494
|
+
let s = raw.trim();
|
|
495
|
+
s = s.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/, "").trim();
|
|
496
|
+
const nl = s.indexOf("\n");
|
|
497
|
+
if (nl === -1) return { subject: s, body: "" };
|
|
498
|
+
return {
|
|
499
|
+
subject: s.slice(0, nl).trim(),
|
|
500
|
+
body: s.slice(nl + 1).trim(),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const COMMIT_SYSTEM = `You write Conventional Commit messages for git.
|
|
505
|
+
|
|
506
|
+
Rules:
|
|
507
|
+
- Output ONLY the commit message — no prose, no code fences, no preamble.
|
|
508
|
+
- Subject line: <type>(<optional-scope>): <imperative summary>. Max 72 chars. No trailing period.
|
|
509
|
+
- Allowed types: feat, fix, chore, docs, refactor, test, perf, build, ci, style, revert.
|
|
510
|
+
- After the subject line, optionally add a blank line and a wrapped body explaining the *why*, not the *what*. Keep body under 6 lines.
|
|
511
|
+
- Use the imperative mood ("add", not "added").`;
|
|
512
|
+
|
|
513
|
+
const PR_BODY_SYSTEM = `You write GitHub Pull Request descriptions in markdown.
|
|
514
|
+
|
|
515
|
+
Rules:
|
|
516
|
+
- Output ONLY the markdown body — no preamble, no code fences around the whole thing.
|
|
517
|
+
- Structure: a 1-2 sentence summary, then a "## Changes" bullet list (one bullet per logical change), then a "## Why" paragraph, then an optional "## Notes" section for caveats.
|
|
518
|
+
- Be concrete and specific to the diff. Reference filenames where helpful.
|
|
519
|
+
- Keep it under 30 lines.`;
|
|
520
|
+
|
|
521
|
+
type ShipPhase =
|
|
522
|
+
| { kind: "summary" }
|
|
523
|
+
| { kind: "branch-input"; suggested: string; input: string; err?: string }
|
|
524
|
+
| { kind: "drafting-commit" }
|
|
525
|
+
| { kind: "commit-review"; subject: string; body: string }
|
|
526
|
+
| { kind: "commit-instruct"; subject: string; body: string; input: string }
|
|
527
|
+
| { kind: "confirm"; subject: string; body: string }
|
|
528
|
+
| { kind: "shipping"; status: string }
|
|
529
|
+
| { kind: "done"; ok: boolean; message: string };
|
|
530
|
+
|
|
531
|
+
interface ShipDeps {
|
|
532
|
+
ctx: ExtensionContext;
|
|
533
|
+
pi: ExtensionAPI;
|
|
534
|
+
invalidate: () => void;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
class ShipMode {
|
|
538
|
+
phase: ShipPhase = { kind: "summary" };
|
|
539
|
+
repo: string;
|
|
540
|
+
currentBranch: string;
|
|
541
|
+
defaultBranch: string;
|
|
542
|
+
isDefaultBranch: boolean;
|
|
543
|
+
repoName: string;
|
|
544
|
+
targetBranch = "";
|
|
545
|
+
filesAbs: string[];
|
|
546
|
+
prUrl: string | null = null;
|
|
547
|
+
|
|
548
|
+
constructor(
|
|
549
|
+
repo: string,
|
|
550
|
+
filesAbs: string[],
|
|
551
|
+
private deps: ShipDeps,
|
|
552
|
+
) {
|
|
553
|
+
this.repo = repo;
|
|
554
|
+
this.filesAbs = filesAbs;
|
|
555
|
+
this.currentBranch = gitCurrentBranch(repo);
|
|
556
|
+
this.defaultBranch = gitDefaultBranch(repo);
|
|
557
|
+
this.isDefaultBranch = ["main", "master", "develop"].includes(this.currentBranch);
|
|
558
|
+
this.repoName = getRepoInfo(repo).name;
|
|
559
|
+
this.targetBranch = this.isDefaultBranch ? "" : this.currentBranch;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private setPhase(p: ShipPhase) {
|
|
563
|
+
this.phase = p;
|
|
564
|
+
this.deps.invalidate();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
handleInput(data: string): "handled" | "exit" {
|
|
568
|
+
const p = this.phase;
|
|
569
|
+
|
|
570
|
+
if (p.kind === "done") {
|
|
571
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || data === "q") return "exit";
|
|
572
|
+
return "handled";
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (p.kind === "shipping") return "handled"; // ignore input during ship
|
|
576
|
+
|
|
577
|
+
if (matchesKey(data, Key.escape)) return "exit";
|
|
578
|
+
|
|
579
|
+
if (p.kind === "summary") {
|
|
580
|
+
if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
|
|
581
|
+
if (this.isDefaultBranch) {
|
|
582
|
+
this.setPhase({ kind: "branch-input", suggested: "", input: "" });
|
|
583
|
+
} else {
|
|
584
|
+
this.startDraftCommit();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return "handled";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (p.kind === "branch-input") {
|
|
591
|
+
if (matchesKey(data, Key.enter)) {
|
|
592
|
+
const name = (p.input || p.suggested).trim();
|
|
593
|
+
if (!name || !/^[A-Za-z0-9._\/-]+$/.test(name)) {
|
|
594
|
+
this.setPhase({ ...p, err: "Invalid branch name." });
|
|
595
|
+
return "handled";
|
|
596
|
+
}
|
|
597
|
+
this.targetBranch = name;
|
|
598
|
+
this.startDraftCommit();
|
|
599
|
+
return "handled";
|
|
600
|
+
}
|
|
601
|
+
if (matchesKey(data, Key.backspace)) {
|
|
602
|
+
this.setPhase({ ...p, input: p.input.slice(0, -1) });
|
|
603
|
+
return "handled";
|
|
604
|
+
}
|
|
605
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
606
|
+
this.setPhase({ ...p, input: p.input + data });
|
|
607
|
+
return "handled";
|
|
608
|
+
}
|
|
609
|
+
return "handled";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (p.kind === "commit-review") {
|
|
613
|
+
if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
|
|
614
|
+
this.setPhase({ kind: "confirm", subject: p.subject, body: p.body });
|
|
615
|
+
return "handled";
|
|
616
|
+
}
|
|
617
|
+
if (data === "r" || data === "R") {
|
|
618
|
+
this.startDraftCommit();
|
|
619
|
+
return "handled";
|
|
620
|
+
}
|
|
621
|
+
if (data === "e" || data === "E") {
|
|
622
|
+
this.setPhase({ kind: "commit-instruct", subject: p.subject, body: p.body, input: "" });
|
|
623
|
+
return "handled";
|
|
624
|
+
}
|
|
625
|
+
return "handled";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (p.kind === "commit-instruct") {
|
|
629
|
+
if (matchesKey(data, Key.enter)) {
|
|
630
|
+
const instr = p.input.trim();
|
|
631
|
+
if (!instr) {
|
|
632
|
+
this.setPhase({ kind: "commit-review", subject: p.subject, body: p.body });
|
|
633
|
+
return "handled";
|
|
634
|
+
}
|
|
635
|
+
this.startDraftCommit(instr, p.subject, p.body);
|
|
636
|
+
return "handled";
|
|
637
|
+
}
|
|
638
|
+
if (matchesKey(data, Key.backspace)) {
|
|
639
|
+
this.setPhase({ ...p, input: p.input.slice(0, -1) });
|
|
640
|
+
return "handled";
|
|
641
|
+
}
|
|
642
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
643
|
+
this.setPhase({ ...p, input: p.input + data });
|
|
644
|
+
return "handled";
|
|
645
|
+
}
|
|
646
|
+
return "handled";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (p.kind === "confirm") {
|
|
650
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
651
|
+
this.doShip(p.subject, p.body);
|
|
652
|
+
return "handled";
|
|
653
|
+
}
|
|
654
|
+
if (data === "n" || data === "N") {
|
|
655
|
+
this.setPhase({ kind: "commit-review", subject: p.subject, body: p.body });
|
|
656
|
+
return "handled";
|
|
657
|
+
}
|
|
658
|
+
return "handled";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return "handled";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private startDraftCommit(instruction?: string, prevSubject?: string, prevBody?: string) {
|
|
665
|
+
this.setPhase({ kind: "drafting-commit" });
|
|
666
|
+
(async () => {
|
|
667
|
+
try {
|
|
668
|
+
// Stage files now so we can diff --cached.
|
|
669
|
+
const add = gitAddPaths(this.repo, this.filesAbs);
|
|
670
|
+
if (!add.ok) throw new Error(`git add failed: ${add.err}`);
|
|
671
|
+
let diff = gitStagedDiff(this.repo);
|
|
672
|
+
if (diff.length > 32_000) diff = diff.slice(0, 32_000) + "\n\n[diff truncated]";
|
|
673
|
+
const parts: string[] = [];
|
|
674
|
+
if (instruction) {
|
|
675
|
+
parts.push(`The previous draft was:\n\n${prevSubject ?? ""}\n${prevBody ? "\n" + prevBody : ""}`);
|
|
676
|
+
parts.push(`The user asked: ${instruction}`);
|
|
677
|
+
parts.push("Please produce a revised commit message that follows the user's instruction. Output ONLY the commit message.");
|
|
678
|
+
}
|
|
679
|
+
parts.push(`Staged diff:\n\n${diff || "(empty diff)"}`);
|
|
680
|
+
const raw = await silentComplete(this.deps.ctx, COMMIT_SYSTEM, parts.join("\n\n"));
|
|
681
|
+
const draft = parseCommitDraft(raw);
|
|
682
|
+
this.setPhase({ kind: "commit-review", subject: draft.subject, body: draft.body });
|
|
683
|
+
} catch (e: any) {
|
|
684
|
+
this.setPhase({
|
|
685
|
+
kind: "done",
|
|
686
|
+
ok: false,
|
|
687
|
+
message: `Failed to draft commit: ${e?.message ?? String(e)}`,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async doShip(subject: string, body: string) {
|
|
694
|
+
const step = (s: string) => this.setPhase({ kind: "shipping", status: s });
|
|
695
|
+
try {
|
|
696
|
+
if (this.isDefaultBranch) {
|
|
697
|
+
step(`Creating branch ${this.targetBranch}…`);
|
|
698
|
+
const co = gitCheckoutNewBranch(this.repo, this.targetBranch);
|
|
699
|
+
if (!co.ok) throw new Error(`checkout -b failed: ${co.err}`);
|
|
700
|
+
}
|
|
701
|
+
step("Staging files…");
|
|
702
|
+
const add = gitAddPaths(this.repo, this.filesAbs);
|
|
703
|
+
if (!add.ok) throw new Error(`git add failed: ${add.err}`);
|
|
704
|
+
if (!gitHasStagedChanges(this.repo)) {
|
|
705
|
+
this.setPhase({
|
|
706
|
+
kind: "done",
|
|
707
|
+
ok: false,
|
|
708
|
+
message: "Nothing to commit — staged diff is empty. Did you already commit these files?",
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
step("Committing…");
|
|
713
|
+
const cm = gitCommit(this.repo, subject, body);
|
|
714
|
+
if (!cm.ok) throw new Error(`git commit failed: ${cm.err}`);
|
|
715
|
+
step(`Pushing origin/${this.targetBranch || this.currentBranch}…`);
|
|
716
|
+
const pushBranch = this.targetBranch || this.currentBranch;
|
|
717
|
+
const ps = gitPush(this.repo, pushBranch);
|
|
718
|
+
if (!ps.ok) throw new Error(`git push failed: ${ps.err}`);
|
|
719
|
+
// PR creation lands in 0.4.0; for now just confirm commit+push.
|
|
720
|
+
this.setPhase({
|
|
721
|
+
kind: "done",
|
|
722
|
+
ok: true,
|
|
723
|
+
message: `Pushed to origin/${pushBranch}.\n\nNext: open a PR (gh pr create) — automated PR flow lands in pi-redline 0.4.0.`,
|
|
724
|
+
});
|
|
725
|
+
} catch (e: any) {
|
|
726
|
+
this.setPhase({
|
|
727
|
+
kind: "done",
|
|
728
|
+
ok: false,
|
|
729
|
+
message: e?.message ?? String(e),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
render(width: number, height: number): string[] {
|
|
735
|
+
const lines: string[] = [];
|
|
736
|
+
const pad = (s: string) => padTo(width, s);
|
|
737
|
+
const h = (s: string) => bold(fg(C.lavender, s));
|
|
738
|
+
const dim = (s: string) => fg(C.overlay1, s);
|
|
739
|
+
const key = (s: string) => fg(C.peach, s);
|
|
740
|
+
const ok = (s: string) => fg(C.green, s);
|
|
741
|
+
const err = (s: string) => fg(C.red, s);
|
|
742
|
+
|
|
743
|
+
lines.push(pad(""));
|
|
744
|
+
lines.push(pad(" " + h("🚀 Ship to GitHub")));
|
|
745
|
+
lines.push(pad(""));
|
|
746
|
+
lines.push(pad(" " + dim("repo: ") + fg(C.text, this.repoName)));
|
|
747
|
+
const slug = gitRepoSlug(this.repo);
|
|
748
|
+
if (slug) lines.push(pad(" " + dim("remote: ") + fg(C.subtext0, slug)));
|
|
749
|
+
lines.push(pad(" " + dim("branch: ") + fg(C.text, this.currentBranch) + dim(" (default: " + this.defaultBranch + ")")));
|
|
750
|
+
lines.push(pad(" " + dim("files: ") + fg(C.text, String(this.filesAbs.length))));
|
|
751
|
+
lines.push(pad(""));
|
|
752
|
+
|
|
753
|
+
const p = this.phase;
|
|
754
|
+
if (p.kind === "summary") {
|
|
755
|
+
if (this.isDefaultBranch) {
|
|
756
|
+
lines.push(pad(" " + fg(C.yellow, "⚠ ") + fg(C.subtext0, `You're on "${this.currentBranch}". You'll be asked for a new branch name.`)));
|
|
757
|
+
} else {
|
|
758
|
+
lines.push(pad(" " + ok("✓ ") + fg(C.subtext0, `Will commit to current branch "${this.currentBranch}".`)));
|
|
759
|
+
}
|
|
760
|
+
lines.push(pad(""));
|
|
761
|
+
lines.push(pad(" " + dim("This will:")));
|
|
762
|
+
lines.push(pad(" " + dim(" 1. Stage the " + this.filesAbs.length + " file" + (this.filesAbs.length === 1 ? "" : "s") + " touched in this session")));
|
|
763
|
+
lines.push(pad(" " + dim(" 2. Draft a commit message with the LLM (silent — no chat pollution)")));
|
|
764
|
+
lines.push(pad(" " + dim(" 3. Let you accept/regenerate/instruct")));
|
|
765
|
+
lines.push(pad(" " + dim(" 4. Commit and push to origin")));
|
|
766
|
+
} else if (p.kind === "branch-input") {
|
|
767
|
+
const sugg = p.suggested ? dim(` (suggested: ${p.suggested})`) : "";
|
|
768
|
+
lines.push(pad(" " + h("New branch name") + sugg));
|
|
769
|
+
lines.push(pad(" " + fg(C.peach, "› ") + fg(C.text, p.input) + fg(C.peach, "▏")));
|
|
770
|
+
if (p.err) lines.push(pad(" " + err(p.err)));
|
|
771
|
+
} else if (p.kind === "drafting-commit") {
|
|
772
|
+
lines.push(pad(" " + fg(C.sapphire, "⏳ ") + fg(C.subtext0, "Drafting commit message with the LLM…")));
|
|
773
|
+
} else if (p.kind === "commit-review" || p.kind === "confirm") {
|
|
774
|
+
lines.push(pad(" " + h("Commit message")));
|
|
775
|
+
lines.push(pad(""));
|
|
776
|
+
lines.push(pad(" " + bold(fg(C.text, p.subject))));
|
|
777
|
+
if (p.body) {
|
|
778
|
+
lines.push(pad(""));
|
|
779
|
+
for (const ln of p.body.split("\n")) {
|
|
780
|
+
lines.push(pad(" " + fg(C.subtext0, ln)));
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
lines.push(pad(""));
|
|
784
|
+
if (p.kind === "confirm") {
|
|
785
|
+
const branch = this.targetBranch || this.currentBranch;
|
|
786
|
+
lines.push(pad(" " + fg(C.yellow, "⚠ ") + fg(C.text, "Ship to ") + bold(fg(C.peach, `origin/${branch}`)) + fg(C.text, "?")));
|
|
787
|
+
}
|
|
788
|
+
} else if (p.kind === "commit-instruct") {
|
|
789
|
+
lines.push(pad(" " + h("How should the commit message change?")));
|
|
790
|
+
lines.push(pad(" " + dim('e.g. "make it shorter", "mention IN-2170", "use chore type"')));
|
|
791
|
+
lines.push(pad(" " + fg(C.peach, "› ") + fg(C.text, p.input) + fg(C.peach, "▏")));
|
|
792
|
+
} else if (p.kind === "shipping") {
|
|
793
|
+
lines.push(pad(" " + fg(C.sapphire, "⏳ ") + fg(C.subtext0, p.status)));
|
|
794
|
+
} else if (p.kind === "done") {
|
|
795
|
+
const icon = p.ok ? ok("✓ ") : err("✗ ");
|
|
796
|
+
lines.push(pad(" " + icon + (p.ok ? ok("Success") : err("Failed"))));
|
|
797
|
+
lines.push(pad(""));
|
|
798
|
+
for (const ln of p.message.split("\n")) {
|
|
799
|
+
lines.push(pad(" " + fg(C.subtext0, ln)));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
while (lines.length < height) lines.push(pad(""));
|
|
804
|
+
return lines.slice(0, height);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
renderFooter(width: number): string {
|
|
808
|
+
const seg = (k: string, v: string) =>
|
|
809
|
+
`${fg(C.peach, k)} ${fg(C.subtext0, v)}`;
|
|
810
|
+
const p = this.phase;
|
|
811
|
+
let segs: [string, string][] = [];
|
|
812
|
+
switch (p.kind) {
|
|
813
|
+
case "summary":
|
|
814
|
+
segs = [["⏎/y", "begin"], ["esc", "cancel ship"]];
|
|
815
|
+
break;
|
|
816
|
+
case "branch-input":
|
|
817
|
+
segs = [["⏎", "confirm branch"], ["esc", "cancel ship"]];
|
|
818
|
+
break;
|
|
819
|
+
case "drafting-commit":
|
|
820
|
+
case "shipping":
|
|
821
|
+
segs = [["esc", "cancel ship"]];
|
|
822
|
+
break;
|
|
823
|
+
case "commit-review":
|
|
824
|
+
segs = [["⏎/y", "accept"], ["r", "regenerate"], ["e", "instruct LLM"], ["esc", "cancel"]];
|
|
825
|
+
break;
|
|
826
|
+
case "commit-instruct":
|
|
827
|
+
segs = [["⏎", "send instruction"], ["esc", "cancel"]];
|
|
828
|
+
break;
|
|
829
|
+
case "confirm":
|
|
830
|
+
segs = [["y", "ship it"], ["n", "back to commit"], ["esc", "cancel"]];
|
|
831
|
+
break;
|
|
832
|
+
case "done":
|
|
833
|
+
segs = [["⏎", "close"]];
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
return padTo(width, " " + segs.map(([k, v]) => seg(k, v)).join(" · "));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
318
840
|
interface RenderedDiff {
|
|
319
841
|
rows: string[]; // colored, gutter-prefixed, wrapped lines ready to slice
|
|
320
842
|
rowToParsed: number[]; // rendered row index → parsed DiffLine index
|
|
@@ -347,6 +869,9 @@ class SessionDiffView {
|
|
|
347
869
|
private cachedHeight?: number;
|
|
348
870
|
private cachedLines?: string[];
|
|
349
871
|
|
|
872
|
+
// Ship mode (P key) — commit + push touched files. Null when inactive.
|
|
873
|
+
private ship: ShipMode | null = null;
|
|
874
|
+
|
|
350
875
|
public onClose?: () => void;
|
|
351
876
|
public onSubmit?: (prompt: string) => void;
|
|
352
877
|
|
|
@@ -354,10 +879,47 @@ class SessionDiffView {
|
|
|
354
879
|
private files: TouchedFile[],
|
|
355
880
|
/** Annotations live in the extension closure so they survive overlay close. */
|
|
356
881
|
private annotations: Annotation[],
|
|
882
|
+
private shipDeps?: { ctx: ExtensionContext; pi: ExtensionAPI },
|
|
357
883
|
) {
|
|
358
884
|
this.rebuildRows();
|
|
359
885
|
}
|
|
360
886
|
|
|
887
|
+
private startShip(): void {
|
|
888
|
+
if (!this.shipDeps) return;
|
|
889
|
+
// Determine which repo to ship: use the currently selected row's repo if a
|
|
890
|
+
// real git repo, else the first touched repo we can find.
|
|
891
|
+
let repo: string | null = null;
|
|
892
|
+
const sel = this.rows[this.leftSelected];
|
|
893
|
+
if (sel && (sel.kind === "repo" || sel.kind === "repoMeta" || sel.kind === "file")) {
|
|
894
|
+
repo = sel.repo;
|
|
895
|
+
}
|
|
896
|
+
if (!repo) {
|
|
897
|
+
for (const f of this.files) {
|
|
898
|
+
if (f.repo) { repo = f.repo; break; }
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (!repo) {
|
|
902
|
+
this.shipDeps.ctx.ui.notify("No git repo found in this session — nothing to ship.", "warning");
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const filesInRepo = this.files.filter((f) => f.repo === repo).map((f) => f.abs);
|
|
906
|
+
if (filesInRepo.length === 0) {
|
|
907
|
+
this.shipDeps.ctx.ui.notify("No session-touched files in that repo.", "warning");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
this.ship = new ShipMode(repo, filesInRepo, {
|
|
911
|
+
ctx: this.shipDeps.ctx,
|
|
912
|
+
pi: this.shipDeps.pi,
|
|
913
|
+
invalidate: () => this.invalidate(),
|
|
914
|
+
});
|
|
915
|
+
this.invalidate();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private exitShip(): void {
|
|
919
|
+
this.ship = null;
|
|
920
|
+
this.invalidate();
|
|
921
|
+
}
|
|
922
|
+
|
|
361
923
|
// ── Left tree ────────────────────────────────────────────────────────────
|
|
362
924
|
private repoKey(r: string | null): string {
|
|
363
925
|
return r ?? "__none__";
|
|
@@ -532,6 +1094,17 @@ class SessionDiffView {
|
|
|
532
1094
|
|
|
533
1095
|
// ── Input ────────────────────────────────────────────────────────────────
|
|
534
1096
|
handleInput(data: string): void {
|
|
1097
|
+
// Ship mode takes over input entirely while active.
|
|
1098
|
+
if (this.ship) {
|
|
1099
|
+
const r = this.ship.handleInput(data);
|
|
1100
|
+
if (r === "exit") this.exitShip();
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
// P enters ship mode from any non-text-input mode.
|
|
1104
|
+
if (data === "P" && this.mode !== "annotate") {
|
|
1105
|
+
this.startShip();
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
535
1108
|
// Annotate mode: capture text input.
|
|
536
1109
|
if (this.mode === "annotate") {
|
|
537
1110
|
if (matchesKey(data, Key.escape)) {
|
|
@@ -919,6 +1492,26 @@ class SessionDiffView {
|
|
|
919
1492
|
const dividerH = 2;
|
|
920
1493
|
const bodyH = Math.max(3, totalHeight - 2 - headerH - footerH - dividerH);
|
|
921
1494
|
|
|
1495
|
+
// Ship mode: replace the body with a single full-width ship UI.
|
|
1496
|
+
if (this.ship) {
|
|
1497
|
+
const border = (s: string) => fg(C.mauve, s);
|
|
1498
|
+
const top = border(`╭${"─".repeat(innerW)}╮`);
|
|
1499
|
+
const bot = border(`╰${"─".repeat(innerW)}╯`);
|
|
1500
|
+
const rule = border("├") + fg(C.surface1, "─".repeat(innerW)) + border("┤");
|
|
1501
|
+
const V = border("│");
|
|
1502
|
+
const hdr = bold(fg(C.lavender, " ✨ Ship to GitHub ")) + fg(C.overlay1, " pi-redline");
|
|
1503
|
+
const header = `${V}${padTo(innerW, hdr)}${V}`;
|
|
1504
|
+
const shipBody = this.ship.render(innerW, bodyH);
|
|
1505
|
+
const body = shipBody.map((l) => `${V}${padTo(innerW, l)}${V}`);
|
|
1506
|
+
const footer = `${V}${padTo(innerW, this.ship.renderFooter(innerW))}${V}`;
|
|
1507
|
+
const lines = [top, header, rule, ...body, rule, footer, bot];
|
|
1508
|
+
const tinted = lines.map((ln) => bg(C.mantle, padTo(width, ln)));
|
|
1509
|
+
this.cachedLines = tinted;
|
|
1510
|
+
this.cachedWidth = width;
|
|
1511
|
+
this.cachedHeight = totalHeight;
|
|
1512
|
+
return tinted;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
922
1515
|
this.lastRightW = rightWidth - 1; // 1 for inner left padding
|
|
923
1516
|
this.lastInnerH = bodyH - 2; // 2 = file header + rule inside right pane
|
|
924
1517
|
|
|
@@ -1011,6 +1604,7 @@ class SessionDiffView {
|
|
|
1011
1604
|
["←", "collapse"],
|
|
1012
1605
|
["→/⏎", "open diff"],
|
|
1013
1606
|
["A", `review (${this.annotations.length})`],
|
|
1607
|
+
["P", "ship"],
|
|
1014
1608
|
["q", "close"],
|
|
1015
1609
|
];
|
|
1016
1610
|
} else if (this.mode === "select") {
|
|
@@ -1039,6 +1633,7 @@ class SessionDiffView {
|
|
|
1039
1633
|
["←", "back"],
|
|
1040
1634
|
["A", `review (${this.annotations.length})`],
|
|
1041
1635
|
["S", "submit"],
|
|
1636
|
+
["P", "ship"],
|
|
1042
1637
|
["q", "close"],
|
|
1043
1638
|
];
|
|
1044
1639
|
}
|
|
@@ -1262,7 +1857,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1262
1857
|
let pendingPrompt: string | null = null;
|
|
1263
1858
|
await ctx.ui.custom<void>(
|
|
1264
1859
|
(tui, _theme, _kb, done) => {
|
|
1265
|
-
const view = new SessionDiffView(files, annotations);
|
|
1860
|
+
const view = new SessionDiffView(files, annotations, { ctx, pi });
|
|
1266
1861
|
view.onClose = () => done(undefined);
|
|
1267
1862
|
view.onSubmit = (prompt) => {
|
|
1268
1863
|
pendingPrompt = prompt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-redline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Redline pi sessions: an overlay TUI showing every file changed in the current pi session with syntax-highlighted diffs, per-line selection, fix/explain annotations, and a one-key submit-as-prompt flow.",
|
|
5
5
|
"author": "Alon Martin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
"node": ">=20"
|
|
10
10
|
},
|
|
11
11
|
"peerDependencies": {
|
|
12
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
12
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
13
|
+
"@earendil-works/pi-ai": "*"
|
|
13
14
|
},
|
|
14
15
|
"peerDependenciesMeta": {
|
|
15
16
|
"@earendil-works/pi-coding-agent": {
|
|
16
17
|
"optional": false
|
|
18
|
+
},
|
|
19
|
+
"@earendil-works/pi-ai": {
|
|
20
|
+
"optional": false
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"pi": {
|