pi-studio 0.5.11 → 0.5.12

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 (3) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/index.ts +324 -3
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.12] — 2026-03-15
8
+
9
+ ### Added
10
+ - Studio now has a `Load git diff` button that loads the current git changes (staged + unstaged tracked changes plus untracked text files) into the editor from the current Studio context and sets the editor language to `diff`.
11
+
7
12
  ## [0.5.11] — 2026-03-15
8
13
 
9
14
  ### Added
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@mariozechner/pi-coding-agent";
2
- import { spawn } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, statSync, writeFileSync } from "node:fs";
5
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -120,6 +120,13 @@ interface GetFromEditorRequestMessage {
120
120
  requestId: string;
121
121
  }
122
122
 
123
+ interface LoadGitDiffRequestMessage {
124
+ type: "load_git_diff_request";
125
+ requestId: string;
126
+ sourcePath?: string;
127
+ resourceDir?: string;
128
+ }
129
+
123
130
  interface CancelRequestMessage {
124
131
  type: "cancel_request";
125
132
  requestId: string;
@@ -137,6 +144,7 @@ type IncomingStudioMessage =
137
144
  | SaveOverRequestMessage
138
145
  | SendToEditorRequestMessage
139
146
  | GetFromEditorRequestMessage
147
+ | LoadGitDiffRequestMessage
140
148
  | CancelRequestMessage;
141
149
 
142
150
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
@@ -904,6 +912,207 @@ function writeStudioFile(pathArg: string, cwd: string, content: string):
904
912
  }
905
913
  }
906
914
 
915
+ function splitStudioGitPathOutput(output: string): string[] {
916
+ return output
917
+ .split(/\r?\n/)
918
+ .map((line) => line.trim())
919
+ .filter((line) => line.length > 0);
920
+ }
921
+
922
+ function formatStudioGitSpawnFailure(
923
+ result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null },
924
+ args: string[],
925
+ ): string {
926
+ const stderr = typeof result.stderr === "string"
927
+ ? result.stderr.trim()
928
+ : (result.stderr ? result.stderr.toString("utf-8").trim() : "");
929
+ const stdout = typeof result.stdout === "string"
930
+ ? result.stdout.trim()
931
+ : (result.stdout ? result.stdout.toString("utf-8").trim() : "");
932
+ return stderr || stdout || `git ${args.join(" ")} failed`;
933
+ }
934
+
935
+ function readStudioTextFileIfPossible(path: string): string | null {
936
+ try {
937
+ const buf = readFileSync(path);
938
+ const sample = buf.subarray(0, 8192);
939
+ let nulCount = 0;
940
+ let controlCount = 0;
941
+ for (let i = 0; i < sample.length; i++) {
942
+ const b = sample[i];
943
+ if (b === 0x00) nulCount += 1;
944
+ else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount += 1;
945
+ }
946
+ if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
947
+ return null;
948
+ }
949
+ return buf.toString("utf-8").replace(/\r\n/g, "\n");
950
+ } catch {
951
+ return null;
952
+ }
953
+ }
954
+
955
+ function buildStudioSyntheticNewFileDiff(filePath: string, content: string): string {
956
+ const lines = content.split("\n");
957
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
958
+ lines.pop();
959
+ }
960
+
961
+ const diffLines = [
962
+ `diff --git a/${filePath} b/${filePath}`,
963
+ "new file mode 100644",
964
+ "--- /dev/null",
965
+ `+++ b/${filePath}`,
966
+ `@@ -0,0 +1,${lines.length} @@`,
967
+ ];
968
+
969
+ if (lines.length > 0) {
970
+ diffLines.push(lines.map((line) => `+${line}`).join("\n"));
971
+ }
972
+
973
+ return diffLines.join("\n");
974
+ }
975
+
976
+ function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
977
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
978
+ if (source) {
979
+ return dirname(source);
980
+ }
981
+
982
+ const resource = typeof resourceDir === "string" ? resourceDir.trim() : "";
983
+ if (resource) {
984
+ return isAbsolute(resource) ? resource : resolve(fallbackCwd, resource);
985
+ }
986
+
987
+ return fallbackCwd;
988
+ }
989
+
990
+ function readStudioGitDiff(baseDir: string):
991
+ | { ok: true; text: string; label: string }
992
+ | { ok: false; level: "info" | "warning" | "error"; message: string } {
993
+ const repoRootArgs = ["rev-parse", "--show-toplevel"];
994
+ const repoRootResult = spawnSync("git", repoRootArgs, {
995
+ cwd: baseDir,
996
+ encoding: "utf-8",
997
+ });
998
+ if (repoRootResult.status !== 0) {
999
+ return {
1000
+ ok: false,
1001
+ level: "warning",
1002
+ message: "No git repository found for the current Studio context.",
1003
+ };
1004
+ }
1005
+ const repoRoot = repoRootResult.stdout.trim();
1006
+
1007
+ const hasHead = spawnSync("git", ["rev-parse", "--verify", "HEAD"], {
1008
+ cwd: repoRoot,
1009
+ encoding: "utf-8",
1010
+ }).status === 0;
1011
+
1012
+ const untrackedArgs = ["ls-files", "--others", "--exclude-standard"];
1013
+ const untrackedResult = spawnSync("git", untrackedArgs, {
1014
+ cwd: repoRoot,
1015
+ encoding: "utf-8",
1016
+ });
1017
+ if (untrackedResult.status !== 0) {
1018
+ return {
1019
+ ok: false,
1020
+ level: "error",
1021
+ message: `Failed to list untracked files: ${formatStudioGitSpawnFailure(untrackedResult, untrackedArgs)}`,
1022
+ };
1023
+ }
1024
+ const untrackedPaths = splitStudioGitPathOutput(untrackedResult.stdout ?? "").sort();
1025
+
1026
+ let diffOutput = "";
1027
+ let statSummary = "";
1028
+ let currentTreeFileCount = 0;
1029
+
1030
+ if (hasHead) {
1031
+ const diffArgs = ["diff", "HEAD", "--unified=3", "--find-renames", "--no-color", "--"];
1032
+ const diffResult = spawnSync("git", diffArgs, {
1033
+ cwd: repoRoot,
1034
+ encoding: "utf-8",
1035
+ });
1036
+ if (diffResult.status !== 0) {
1037
+ return {
1038
+ ok: false,
1039
+ level: "error",
1040
+ message: `Failed to collect git diff: ${formatStudioGitSpawnFailure(diffResult, diffArgs)}`,
1041
+ };
1042
+ }
1043
+ diffOutput = diffResult.stdout ?? "";
1044
+
1045
+ const statArgs = ["diff", "HEAD", "--stat", "--find-renames", "--no-color", "--"];
1046
+ const statResult = spawnSync("git", statArgs, {
1047
+ cwd: repoRoot,
1048
+ encoding: "utf-8",
1049
+ });
1050
+ if (statResult.status === 0) {
1051
+ const statLines = splitStudioGitPathOutput(statResult.stdout ?? "");
1052
+ statSummary = statLines.length > 0 ? (statLines[statLines.length - 1] ?? "") : "";
1053
+ }
1054
+ } else {
1055
+ const trackedArgs = ["ls-files", "--cached"];
1056
+ const trackedResult = spawnSync("git", trackedArgs, {
1057
+ cwd: repoRoot,
1058
+ encoding: "utf-8",
1059
+ });
1060
+ if (trackedResult.status !== 0) {
1061
+ return {
1062
+ ok: false,
1063
+ level: "error",
1064
+ message: `Failed to inspect tracked files: ${formatStudioGitSpawnFailure(trackedResult, trackedArgs)}`,
1065
+ };
1066
+ }
1067
+
1068
+ const trackedPaths = splitStudioGitPathOutput(trackedResult.stdout ?? "");
1069
+ const currentTreePaths = Array.from(new Set([...trackedPaths, ...untrackedPaths])).sort();
1070
+ currentTreeFileCount = currentTreePaths.length;
1071
+ diffOutput = currentTreePaths
1072
+ .map((filePath) => {
1073
+ const content = readStudioTextFileIfPossible(join(repoRoot, filePath));
1074
+ if (content == null) return "";
1075
+ return buildStudioSyntheticNewFileDiff(filePath, content);
1076
+ })
1077
+ .filter((section) => section.length > 0)
1078
+ .join("\n\n");
1079
+ }
1080
+
1081
+ const untrackedSections = hasHead
1082
+ ? untrackedPaths
1083
+ .map((filePath) => {
1084
+ const content = readStudioTextFileIfPossible(join(repoRoot, filePath));
1085
+ if (content == null) return "";
1086
+ return buildStudioSyntheticNewFileDiff(filePath, content);
1087
+ })
1088
+ .filter((section) => section.length > 0)
1089
+ : [];
1090
+
1091
+ const fullDiff = [diffOutput.trimEnd(), ...untrackedSections].filter(Boolean).join("\n\n");
1092
+ if (!fullDiff.trim()) {
1093
+ return {
1094
+ ok: false,
1095
+ level: "info",
1096
+ message: "No uncommitted git changes to load.",
1097
+ };
1098
+ }
1099
+
1100
+ const summaryParts: string[] = [];
1101
+ if (hasHead && statSummary) {
1102
+ summaryParts.push(statSummary);
1103
+ }
1104
+ if (!hasHead && currentTreeFileCount > 0) {
1105
+ summaryParts.push(`${currentTreeFileCount} file${currentTreeFileCount === 1 ? "" : "s"} in current tree`);
1106
+ }
1107
+ if (untrackedPaths.length > 0) {
1108
+ summaryParts.push(`${untrackedPaths.length} untracked file${untrackedPaths.length === 1 ? "" : "s"}`);
1109
+ }
1110
+
1111
+ const labelBase = hasHead ? "git diff HEAD" : "git diff (no commits yet)";
1112
+ const label = summaryParts.length > 0 ? `${labelBase} (${summaryParts.join(", ")})` : labelBase;
1113
+ return { ok: true, text: fullDiff, label };
1114
+ }
1115
+
907
1116
  function readLocalPackageMetadata(): { name: string; version: string } | null {
908
1117
  try {
909
1118
  const raw = readFileSync(new URL("./package.json", import.meta.url), "utf-8");
@@ -1904,6 +2113,20 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
1904
2113
  };
1905
2114
  }
1906
2115
 
2116
+ if (
2117
+ msg.type === "load_git_diff_request"
2118
+ && typeof msg.requestId === "string"
2119
+ && (msg.sourcePath === undefined || typeof msg.sourcePath === "string")
2120
+ && (msg.resourceDir === undefined || typeof msg.resourceDir === "string")
2121
+ ) {
2122
+ return {
2123
+ type: "load_git_diff_request",
2124
+ requestId: msg.requestId,
2125
+ sourcePath: typeof msg.sourcePath === "string" ? msg.sourcePath : undefined,
2126
+ resourceDir: typeof msg.resourceDir === "string" ? msg.resourceDir : undefined,
2127
+ };
2128
+ }
2129
+
1907
2130
  if (msg.type === "cancel_request" && typeof msg.requestId === "string") {
1908
2131
  return {
1909
2132
  type: "cancel_request",
@@ -3258,6 +3481,8 @@ ${cssVarsBlock}
3258
3481
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path.">Save editor as…</button>
3259
3482
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content." disabled>Save editor</button>
3260
3483
  <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
3484
+ <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
3485
+ <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
3261
3486
  </div>
3262
3487
  </header>
3263
3488
 
@@ -3286,7 +3511,6 @@ ${cssVarsBlock}
3286
3511
  <button id="sendRunBtn" type="button" title="Send editor text directly to the model as-is. Shortcut: Cmd/Ctrl+Enter when editor pane is active.">Run editor text</button>
3287
3512
  <button id="copyDraftBtn" type="button">Copy editor text</button>
3288
3513
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
3289
- <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
3290
3514
  </div>
3291
3515
  <div class="source-actions-row">
3292
3516
  <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Insert annotated reply header</button>
@@ -3483,6 +3707,7 @@ ${cssVarsBlock}
3483
3707
  const saveOverBtn = document.getElementById("saveOverBtn");
3484
3708
  const sendEditorBtn = document.getElementById("sendEditorBtn");
3485
3709
  const getEditorBtn = document.getElementById("getEditorBtn");
3710
+ const loadGitDiffBtn = document.getElementById("loadGitDiffBtn");
3486
3711
  const sendRunBtn = document.getElementById("sendRunBtn");
3487
3712
  const copyDraftBtn = document.getElementById("copyDraftBtn");
3488
3713
  const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
@@ -3773,6 +3998,7 @@ ${cssVarsBlock}
3773
3998
  if (kind === "compact") return "compacting context";
3774
3999
  if (kind === "send_to_editor") return "sending to pi editor";
3775
4000
  if (kind === "get_from_editor") return "loading from pi editor";
4001
+ if (kind === "load_git_diff") return "loading git diff";
3776
4002
  if (kind === "save_as" || kind === "save_over") return "saving editor text";
3777
4003
  return "submitting request";
3778
4004
  }
@@ -5163,6 +5389,7 @@ ${cssVarsBlock}
5163
5389
  saveOverBtn.disabled = uiBusy || !canSaveOver;
5164
5390
  sendEditorBtn.disabled = uiBusy;
5165
5391
  if (getEditorBtn) getEditorBtn.disabled = uiBusy;
5392
+ if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
5166
5393
  syncRunAndCritiqueButtons();
5167
5394
  copyDraftBtn.disabled = uiBusy;
5168
5395
  if (highlightSelect) highlightSelect.disabled = uiBusy;
@@ -6362,6 +6589,31 @@ ${cssVarsBlock}
6362
6589
  return;
6363
6590
  }
6364
6591
 
6592
+ if (message.type === "git_diff_snapshot") {
6593
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
6594
+ pendingRequestId = null;
6595
+ pendingKind = null;
6596
+ }
6597
+
6598
+ const content = typeof message.content === "string" ? message.content : "";
6599
+ const label = typeof message.label === "string" && message.label.trim()
6600
+ ? message.label.trim()
6601
+ : "git diff";
6602
+ setEditorText(content, { preserveScroll: false, preserveSelection: false });
6603
+ setSourceState({ source: "blank", label, path: null });
6604
+ setEditorLanguage("diff");
6605
+ setBusy(false);
6606
+ setWsState("Ready");
6607
+ refreshResponseUi();
6608
+ setStatus(
6609
+ typeof message.message === "string" && message.message.trim()
6610
+ ? message.message
6611
+ : "Loaded current git diff.",
6612
+ "success",
6613
+ );
6614
+ return;
6615
+ }
6616
+
6365
6617
  if (message.type === "studio_state") {
6366
6618
  const busy = Boolean(message.busy);
6367
6619
  agentBusyFromServer = Boolean(message.agentBusy);
@@ -6458,8 +6710,17 @@ ${cssVarsBlock}
6458
6710
  }
6459
6711
 
6460
6712
  if (message.type === "info") {
6713
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
6714
+ pendingRequestId = null;
6715
+ pendingKind = null;
6716
+ setBusy(false);
6717
+ setWsState("Ready");
6718
+ }
6461
6719
  if (typeof message.message === "string") {
6462
- setStatus(message.message);
6720
+ setStatus(
6721
+ message.message,
6722
+ typeof message.level === "string" ? message.level : undefined,
6723
+ );
6463
6724
  }
6464
6725
  }
6465
6726
 
@@ -7099,6 +7360,29 @@ ${cssVarsBlock}
7099
7360
  });
7100
7361
  }
7101
7362
 
7363
+ if (loadGitDiffBtn) {
7364
+ loadGitDiffBtn.addEventListener("click", () => {
7365
+ const requestId = beginUiAction("load_git_diff");
7366
+ if (!requestId) return;
7367
+
7368
+ const effectivePath = getEffectiveSavePath();
7369
+ const sent = sendMessage({
7370
+ type: "load_git_diff_request",
7371
+ requestId,
7372
+ sourcePath: effectivePath || sourceState.path || undefined,
7373
+ resourceDir: resourceDirInput && resourceDirInput.value.trim()
7374
+ ? resourceDirInput.value.trim()
7375
+ : undefined,
7376
+ });
7377
+
7378
+ if (!sent) {
7379
+ pendingRequestId = null;
7380
+ pendingKind = null;
7381
+ setBusy(false);
7382
+ }
7383
+ });
7384
+ }
7385
+
7102
7386
  sendRunBtn.addEventListener("click", () => {
7103
7387
  if (getAbortablePendingKind() === "direct") {
7104
7388
  requestCancelForPendingRequest("direct");
@@ -7709,6 +7993,43 @@ export default function (pi: ExtensionAPI) {
7709
7993
  return;
7710
7994
  }
7711
7995
 
7996
+ if (msg.type === "load_git_diff_request") {
7997
+ if (!isValidRequestId(msg.requestId)) {
7998
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
7999
+ return;
8000
+ }
8001
+ if (isStudioBusy()) {
8002
+ sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
8003
+ return;
8004
+ }
8005
+
8006
+ const baseDir = resolveStudioGitDiffBaseDir(msg.sourcePath, msg.resourceDir, studioCwd);
8007
+ const diffResult = readStudioGitDiff(baseDir);
8008
+ if (!diffResult.ok) {
8009
+ sendToClient(client, {
8010
+ type: "info",
8011
+ requestId: msg.requestId,
8012
+ message: diffResult.message,
8013
+ level: diffResult.level,
8014
+ });
8015
+ return;
8016
+ }
8017
+
8018
+ initialStudioDocument = {
8019
+ text: diffResult.text,
8020
+ label: diffResult.label,
8021
+ source: "blank",
8022
+ };
8023
+ sendToClient(client, {
8024
+ type: "git_diff_snapshot",
8025
+ requestId: msg.requestId,
8026
+ content: diffResult.text,
8027
+ label: diffResult.label,
8028
+ message: "Loaded current git diff into Studio.",
8029
+ });
8030
+ return;
8031
+ }
8032
+
7712
8033
  if (msg.type === "cancel_request") {
7713
8034
  if (!isValidRequestId(msg.requestId)) {
7714
8035
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",