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.
@@ -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
- return r.stdout ?? "";
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.1.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": {