@tt-a1i/openpi 0.1.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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { Effect } from "effect";
|
|
10
|
+
import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
|
|
11
|
+
import { runCommand } from "./process.ts";
|
|
12
|
+
|
|
13
|
+
export { sanitizeTerminalText };
|
|
14
|
+
|
|
15
|
+
const DIFF_SCROLL_STEP = 5;
|
|
16
|
+
const MAX_DIFF_LINES = 20_000;
|
|
17
|
+
|
|
18
|
+
interface ChangedPath {
|
|
19
|
+
path: string;
|
|
20
|
+
status: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChangedFile {
|
|
24
|
+
additions: number | null;
|
|
25
|
+
deletions: number | null;
|
|
26
|
+
diff: string[];
|
|
27
|
+
name: string;
|
|
28
|
+
path: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseChangedPaths(output: string) {
|
|
32
|
+
const records = output.split("\0");
|
|
33
|
+
const paths: ChangedPath[] = [];
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
36
|
+
const record = records[index];
|
|
37
|
+
if (!record || record.length < 4) continue;
|
|
38
|
+
|
|
39
|
+
const status = record.slice(0, 2);
|
|
40
|
+
const path = record.slice(3);
|
|
41
|
+
paths.push({ path, status });
|
|
42
|
+
|
|
43
|
+
// In porcelain v1 -z output, rename/copy records are followed by the old path.
|
|
44
|
+
if (status.includes("R") || status.includes("C")) index += 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return [...new Map(paths.map((entry) => [entry.path, entry])).values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseNumstat(output: string) {
|
|
51
|
+
const line = output.split("\n").find(Boolean);
|
|
52
|
+
if (!line) return { additions: 0, deletions: 0 };
|
|
53
|
+
|
|
54
|
+
const [added, deleted] = line.split("\t");
|
|
55
|
+
return {
|
|
56
|
+
additions: added === "-" ? null : Number.parseInt(added ?? "0", 10),
|
|
57
|
+
deletions: deleted === "-" ? null : Number.parseInt(deleted ?? "0", 10),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cleanDisplayPath(path: string) {
|
|
62
|
+
return sanitizeTerminalText(path).replace(/[\r\n\t]/g, " ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const run = (cwd: string, args: string[]) =>
|
|
66
|
+
runCommand("git", args, cwd, 10_000);
|
|
67
|
+
|
|
68
|
+
const loadFile = Effect.fn("git-info.loadFile")(function* (
|
|
69
|
+
repoRoot: string,
|
|
70
|
+
changedPath: ChangedPath,
|
|
71
|
+
hasHead: boolean,
|
|
72
|
+
) {
|
|
73
|
+
const useNoIndex = changedPath.status === "??" || !hasHead;
|
|
74
|
+
const diffArguments = useNoIndex
|
|
75
|
+
? [
|
|
76
|
+
"diff",
|
|
77
|
+
"--no-index",
|
|
78
|
+
"--no-ext-diff",
|
|
79
|
+
"--no-color",
|
|
80
|
+
"--unified=3",
|
|
81
|
+
"--",
|
|
82
|
+
"/dev/null",
|
|
83
|
+
changedPath.path,
|
|
84
|
+
]
|
|
85
|
+
: [
|
|
86
|
+
"diff",
|
|
87
|
+
"--no-ext-diff",
|
|
88
|
+
"--no-color",
|
|
89
|
+
"--unified=3",
|
|
90
|
+
"HEAD",
|
|
91
|
+
"--",
|
|
92
|
+
changedPath.path,
|
|
93
|
+
];
|
|
94
|
+
const statArguments = useNoIndex
|
|
95
|
+
? ["diff", "--no-index", "--numstat", "--", "/dev/null", changedPath.path]
|
|
96
|
+
: ["diff", "--numstat", "HEAD", "--", changedPath.path];
|
|
97
|
+
const [diffResult, statResult] = yield* Effect.all(
|
|
98
|
+
[run(repoRoot, diffArguments), run(repoRoot, statArguments)],
|
|
99
|
+
{ concurrency: "unbounded" },
|
|
100
|
+
);
|
|
101
|
+
const stats = parseNumstat(statResult.stdout);
|
|
102
|
+
const allDiffLines = diffResult.stdout
|
|
103
|
+
.trimEnd()
|
|
104
|
+
.split("\n")
|
|
105
|
+
.map(sanitizeTerminalText);
|
|
106
|
+
const diff =
|
|
107
|
+
allDiffLines.length > MAX_DIFF_LINES
|
|
108
|
+
? [
|
|
109
|
+
...allDiffLines.slice(0, MAX_DIFF_LINES),
|
|
110
|
+
`… diff truncated after ${MAX_DIFF_LINES.toLocaleString()} lines …`,
|
|
111
|
+
]
|
|
112
|
+
: allDiffLines;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
...stats,
|
|
116
|
+
diff:
|
|
117
|
+
diff.length === 1 && diff[0] === ""
|
|
118
|
+
? ["No textual diff available."]
|
|
119
|
+
: diff,
|
|
120
|
+
name: cleanDisplayPath(basename(changedPath.path)),
|
|
121
|
+
path: cleanDisplayPath(changedPath.path),
|
|
122
|
+
} satisfies ChangedFile;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const loadChangedFiles = Effect.fn("git-info.loadChangedFiles")(
|
|
126
|
+
function* (cwd: string) {
|
|
127
|
+
const rootResult = yield* run(cwd, ["rev-parse", "--show-toplevel"]);
|
|
128
|
+
if (rootResult.code !== 0) return null;
|
|
129
|
+
|
|
130
|
+
const repoRoot = rootResult.stdout.trim();
|
|
131
|
+
const [statusResult, headResult] = yield* Effect.all(
|
|
132
|
+
[
|
|
133
|
+
run(repoRoot, [
|
|
134
|
+
"status",
|
|
135
|
+
"--porcelain=v1",
|
|
136
|
+
"-z",
|
|
137
|
+
"--untracked-files=all",
|
|
138
|
+
]),
|
|
139
|
+
run(repoRoot, ["rev-parse", "--verify", "HEAD"]),
|
|
140
|
+
],
|
|
141
|
+
{ concurrency: "unbounded" },
|
|
142
|
+
);
|
|
143
|
+
if (statusResult.code !== 0) return null;
|
|
144
|
+
|
|
145
|
+
const changedPaths = parseChangedPaths(statusResult.stdout);
|
|
146
|
+
const files: ChangedFile[] = [];
|
|
147
|
+
for (const changedPath of changedPaths) {
|
|
148
|
+
files.push(yield* loadFile(repoRoot, changedPath, headResult.code === 0));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return files;
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
function padToWidth(text: string, width: number) {
|
|
156
|
+
const truncated = truncateToWidth(text, width, "");
|
|
157
|
+
return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function showChangedFiles(
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
files: ChangedFile[],
|
|
163
|
+
) {
|
|
164
|
+
if (ctx.mode !== "tui") return;
|
|
165
|
+
|
|
166
|
+
await ctx.ui.custom<void>(
|
|
167
|
+
(tui, theme, _keybindings, done) => {
|
|
168
|
+
let focus: "files" | "diff" = "files";
|
|
169
|
+
let selectedIndex = 0;
|
|
170
|
+
let sidebarOffset = 0;
|
|
171
|
+
let diffOffset = 0;
|
|
172
|
+
|
|
173
|
+
function bodyHeight() {
|
|
174
|
+
return Math.max(8, Math.floor(tui.terminal.rows * 0.8) - 2);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ensureSelectedFileVisible() {
|
|
178
|
+
const visibleFiles = Math.max(1, Math.floor(bodyHeight() / 2));
|
|
179
|
+
if (selectedIndex < sidebarOffset) sidebarOffset = selectedIndex;
|
|
180
|
+
if (selectedIndex >= sidebarOffset + visibleFiles) {
|
|
181
|
+
sidebarOffset = selectedIndex - visibleFiles + 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function moveFile(amount: number) {
|
|
186
|
+
selectedIndex = (selectedIndex + amount + files.length) % files.length;
|
|
187
|
+
diffOffset = 0;
|
|
188
|
+
ensureSelectedFileVisible();
|
|
189
|
+
tui.requestRender();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function moveDiff(amount: number) {
|
|
193
|
+
const maxOffset = Math.max(
|
|
194
|
+
0,
|
|
195
|
+
files[selectedIndex]!.diff.length - bodyHeight(),
|
|
196
|
+
);
|
|
197
|
+
diffOffset = Math.max(0, Math.min(maxOffset, diffOffset + amount));
|
|
198
|
+
tui.requestRender();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function styleDiffLine(line: string) {
|
|
202
|
+
const expanded = sanitizeTerminalText(line).replaceAll("\t", " ");
|
|
203
|
+
if (
|
|
204
|
+
expanded.startsWith("diff --git") ||
|
|
205
|
+
expanded.startsWith("index ")
|
|
206
|
+
) {
|
|
207
|
+
return theme.fg("accent", theme.bold(expanded));
|
|
208
|
+
}
|
|
209
|
+
if (expanded.startsWith("@@")) return theme.fg("mdHeading", expanded);
|
|
210
|
+
if (expanded.startsWith("---") || expanded.startsWith("+++")) {
|
|
211
|
+
return theme.fg("muted", expanded);
|
|
212
|
+
}
|
|
213
|
+
if (expanded.startsWith("+")) return theme.fg("success", expanded);
|
|
214
|
+
if (expanded.startsWith("-")) return theme.fg("error", expanded);
|
|
215
|
+
if (expanded.startsWith("…")) return theme.fg("warning", expanded);
|
|
216
|
+
return theme.fg("text", expanded);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function border(width: number, label: string, top: boolean) {
|
|
220
|
+
const left = top ? "┌" : "└";
|
|
221
|
+
const right = top ? "┐" : "┘";
|
|
222
|
+
const text = `─ ${label} `;
|
|
223
|
+
const remaining = Math.max(0, width - visibleWidth(text) - 2);
|
|
224
|
+
return theme.fg(
|
|
225
|
+
"borderAccent",
|
|
226
|
+
truncateToWidth(
|
|
227
|
+
`${left}${text}${"─".repeat(remaining)}${right}`,
|
|
228
|
+
width,
|
|
229
|
+
"",
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function handleInput(data: string) {
|
|
235
|
+
if (focus === "files") {
|
|
236
|
+
if (matchesKey(data, Key.escape)) {
|
|
237
|
+
done(undefined);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
241
|
+
moveFile(1);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
245
|
+
moveFile(-1);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (matchesKey(data, Key.home) || data === "g") {
|
|
249
|
+
selectedIndex = 0;
|
|
250
|
+
diffOffset = 0;
|
|
251
|
+
ensureSelectedFileVisible();
|
|
252
|
+
tui.requestRender();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (matchesKey(data, Key.end) || data === "G") {
|
|
256
|
+
selectedIndex = files.length - 1;
|
|
257
|
+
diffOffset = 0;
|
|
258
|
+
ensureSelectedFileVisible();
|
|
259
|
+
tui.requestRender();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
matchesKey(data, Key.enter) ||
|
|
264
|
+
matchesKey(data, Key.space) ||
|
|
265
|
+
matchesKey(data, Key.right) ||
|
|
266
|
+
data === "l"
|
|
267
|
+
) {
|
|
268
|
+
focus = "diff";
|
|
269
|
+
tui.requestRender();
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
matchesKey(data, Key.escape) ||
|
|
276
|
+
matchesKey(data, Key.left) ||
|
|
277
|
+
data === "h"
|
|
278
|
+
) {
|
|
279
|
+
focus = "files";
|
|
280
|
+
tui.requestRender();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
284
|
+
moveDiff(DIFF_SCROLL_STEP);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
288
|
+
moveDiff(-DIFF_SCROLL_STEP);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
292
|
+
moveDiff(Math.max(1, Math.floor(bodyHeight() / 2)));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (matchesKey(data, Key.ctrl("u"))) {
|
|
296
|
+
moveDiff(-Math.max(1, Math.floor(bodyHeight() / 2)));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (matchesKey(data, Key.home) || data === "g") {
|
|
300
|
+
diffOffset = 0;
|
|
301
|
+
tui.requestRender();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (matchesKey(data, Key.end) || data === "G") {
|
|
305
|
+
diffOffset = Math.max(
|
|
306
|
+
0,
|
|
307
|
+
files[selectedIndex]!.diff.length - bodyHeight(),
|
|
308
|
+
);
|
|
309
|
+
tui.requestRender();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function render(width: number) {
|
|
314
|
+
const height = bodyHeight();
|
|
315
|
+
const sidebarWidth = Math.min(
|
|
316
|
+
48,
|
|
317
|
+
Math.max(24, Math.floor(width * 0.34)),
|
|
318
|
+
);
|
|
319
|
+
const diffWidth = Math.max(1, width - sidebarWidth - 3);
|
|
320
|
+
const selectedFile = files[selectedIndex]!;
|
|
321
|
+
const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"} · ${focus === "files" ? "FILES" : "DIFF"}`;
|
|
322
|
+
const lines = [border(width, title, true)];
|
|
323
|
+
|
|
324
|
+
for (let row = 0; row < height; row += 1) {
|
|
325
|
+
const fileIndex = sidebarOffset + Math.floor(row / 2);
|
|
326
|
+
const file = files[fileIndex];
|
|
327
|
+
let sidebar = "";
|
|
328
|
+
|
|
329
|
+
if (file) {
|
|
330
|
+
const isSelected = fileIndex === selectedIndex;
|
|
331
|
+
if (row % 2 === 0) {
|
|
332
|
+
const marker = isSelected ? "› " : " ";
|
|
333
|
+
const isBinary =
|
|
334
|
+
file.additions === null || file.deletions === null;
|
|
335
|
+
const stats = isBinary
|
|
336
|
+
? "binary"
|
|
337
|
+
: `+${file.additions} -${file.deletions}`;
|
|
338
|
+
const styledStats = isBinary
|
|
339
|
+
? theme.fg("success", stats)
|
|
340
|
+
: `${theme.fg("success", `+${file.additions}`)} ${theme.fg("error", `-${file.deletions}`)}`;
|
|
341
|
+
const nameWidth = Math.max(
|
|
342
|
+
1,
|
|
343
|
+
sidebarWidth - visibleWidth(marker) - visibleWidth(stats) - 1,
|
|
344
|
+
);
|
|
345
|
+
const name = truncateToWidth(
|
|
346
|
+
cleanDisplayPath(file.name),
|
|
347
|
+
nameWidth,
|
|
348
|
+
"…",
|
|
349
|
+
);
|
|
350
|
+
const gap = " ".repeat(
|
|
351
|
+
Math.max(
|
|
352
|
+
1,
|
|
353
|
+
sidebarWidth -
|
|
354
|
+
visibleWidth(marker) -
|
|
355
|
+
visibleWidth(name) -
|
|
356
|
+
visibleWidth(stats),
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
sidebar = `${marker}${name}${gap}${styledStats}`;
|
|
360
|
+
} else {
|
|
361
|
+
sidebar = ` ${theme.fg("dim", truncateToWidth(cleanDisplayPath(file.path), Math.max(1, sidebarWidth - 2), "…"))}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
sidebar = padToWidth(sidebar, sidebarWidth);
|
|
365
|
+
if (isSelected) {
|
|
366
|
+
sidebar = theme.bg(
|
|
367
|
+
focus === "files" ? "selectedBg" : "customMessageBg",
|
|
368
|
+
sidebar,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
sidebar = " ".repeat(sidebarWidth);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const diffLine = selectedFile.diff[diffOffset + row];
|
|
376
|
+
const diff = padToWidth(
|
|
377
|
+
diffLine === undefined ? "" : styleDiffLine(diffLine),
|
|
378
|
+
diffWidth,
|
|
379
|
+
);
|
|
380
|
+
const separator = theme.fg(
|
|
381
|
+
focus === "diff" ? "borderAccent" : "borderMuted",
|
|
382
|
+
"│",
|
|
383
|
+
);
|
|
384
|
+
lines.push(
|
|
385
|
+
`${theme.fg("borderMuted", "│")}${sidebar}${separator}${diff}${theme.fg("borderMuted", "│")}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const help =
|
|
390
|
+
focus === "files"
|
|
391
|
+
? "j/k or ↑/↓ select · enter/space/l open diff · esc close"
|
|
392
|
+
: "j/k or ↑/↓ scroll · ctrl-d/u page · g/G top/bottom · esc/h files";
|
|
393
|
+
lines.push(border(width, help, false));
|
|
394
|
+
return lines;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
handleInput,
|
|
399
|
+
invalidate() {},
|
|
400
|
+
render,
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
overlay: true,
|
|
405
|
+
overlayOptions: {
|
|
406
|
+
anchor: "center",
|
|
407
|
+
margin: 1,
|
|
408
|
+
maxHeight: "90%",
|
|
409
|
+
minWidth: 60,
|
|
410
|
+
width: "95%",
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Stream } from "effect";
|
|
2
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
3
|
+
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
4
|
+
|
|
5
|
+
const MAX_STREAM_CHARS = 10 * 1_024 * 1_024;
|
|
6
|
+
const TRUNCATED_MARKER = "\n[command output truncated]\n";
|
|
7
|
+
|
|
8
|
+
function appendBounded(current: string, chunk: string) {
|
|
9
|
+
if (current.endsWith(TRUNCATED_MARKER)) return current;
|
|
10
|
+
if (current.length + chunk.length <= MAX_STREAM_CHARS) return current + chunk;
|
|
11
|
+
const remaining = Math.max(0, MAX_STREAM_CHARS - current.length);
|
|
12
|
+
return `${current}${chunk.slice(0, remaining)}${TRUNCATED_MARKER}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandResult {
|
|
16
|
+
code: number;
|
|
17
|
+
stderr: string;
|
|
18
|
+
stdout: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CommandRunnerShape {
|
|
22
|
+
run(
|
|
23
|
+
command: string,
|
|
24
|
+
args: string[],
|
|
25
|
+
cwd: string,
|
|
26
|
+
timeout: number,
|
|
27
|
+
): Effect.Effect<CommandResult>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class CommandRunner extends Context.Service<
|
|
31
|
+
CommandRunner,
|
|
32
|
+
CommandRunnerShape
|
|
33
|
+
>()("git-info/CommandRunner") {}
|
|
34
|
+
|
|
35
|
+
function appendCommandFailure(stderr: string, command: string, error: Error) {
|
|
36
|
+
const failure = `Failed to run ${command}: ${error.message}`;
|
|
37
|
+
return stderr ? `${stderr.trimEnd()}\n${failure}` : failure;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const CommandRunnerLive = Layer.effect(
|
|
41
|
+
CommandRunner,
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const spawner = yield* ChildProcessSpawner;
|
|
44
|
+
|
|
45
|
+
return CommandRunner.of({
|
|
46
|
+
run: (command, args, cwd, timeout) =>
|
|
47
|
+
Effect.suspend(() => {
|
|
48
|
+
let stderr = "";
|
|
49
|
+
let stdout = "";
|
|
50
|
+
const child = ChildProcess.make(command, args, {
|
|
51
|
+
cwd,
|
|
52
|
+
detached: false,
|
|
53
|
+
forceKillAfter: "5 seconds",
|
|
54
|
+
stdin: "ignore",
|
|
55
|
+
stderr: "pipe",
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return Effect.scoped(
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const handle = yield* spawner.spawn(child);
|
|
62
|
+
const [, , code] = yield* Effect.all(
|
|
63
|
+
[
|
|
64
|
+
Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) =>
|
|
65
|
+
Effect.sync(() => {
|
|
66
|
+
stdout = appendBounded(stdout, chunk);
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
Stream.runForEach(Stream.decodeText(handle.stderr), (chunk) =>
|
|
70
|
+
Effect.sync(() => {
|
|
71
|
+
stderr = appendBounded(stderr, chunk);
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
handle.exitCode,
|
|
75
|
+
],
|
|
76
|
+
{ concurrency: "unbounded" },
|
|
77
|
+
);
|
|
78
|
+
return { code: Number(code), stderr, stdout };
|
|
79
|
+
}),
|
|
80
|
+
).pipe(
|
|
81
|
+
Effect.timeoutOrElse({
|
|
82
|
+
duration: timeout,
|
|
83
|
+
orElse: () => Effect.succeed({ code: -1, stderr, stdout }),
|
|
84
|
+
}),
|
|
85
|
+
Effect.catch((error) =>
|
|
86
|
+
Effect.succeed({
|
|
87
|
+
code: 1,
|
|
88
|
+
stderr: appendCommandFailure(stderr, command, error),
|
|
89
|
+
stdout,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export const runCommand = (
|
|
99
|
+
command: string,
|
|
100
|
+
args: string[],
|
|
101
|
+
cwd: string,
|
|
102
|
+
timeout: number,
|
|
103
|
+
) =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const commands = yield* CommandRunner;
|
|
106
|
+
return yield* commands.run(command, args, cwd, timeout);
|
|
107
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect, Semaphore } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Serializes explicit refreshes while allowing background refreshes to coalesce. */
|
|
4
|
+
export function makeRefreshCoordinator() {
|
|
5
|
+
const semaphore = Semaphore.makeUnsafe(1);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
9
|
+
semaphore.withPermit(effect),
|
|
10
|
+
runIfIdle: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
11
|
+
semaphore.withPermitsIfAvailable(1)(effect).pipe(Effect.asVoid),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { Cause, Exit, Layer, ManagedRuntime, type Effect } from "effect";
|
|
3
|
+
import { CommandRunner, CommandRunnerLive } from "./process.ts";
|
|
4
|
+
|
|
5
|
+
const AppLayer = CommandRunnerLive.pipe(Layer.provide(NodeServices.layer));
|
|
6
|
+
|
|
7
|
+
export function createRuntime() {
|
|
8
|
+
return ManagedRuntime.make(AppLayer);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type GitInfoRuntime = ReturnType<typeof createRuntime>;
|
|
12
|
+
|
|
13
|
+
export async function runEffect<A, E>(
|
|
14
|
+
runtime: GitInfoRuntime,
|
|
15
|
+
effect: Effect.Effect<A, E, CommandRunner>,
|
|
16
|
+
options: { signal?: AbortSignal; interruptMessage?: string } = {},
|
|
17
|
+
) {
|
|
18
|
+
const exit = await runtime.runPromiseExit(
|
|
19
|
+
effect,
|
|
20
|
+
options.signal ? { signal: options.signal } : undefined,
|
|
21
|
+
);
|
|
22
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
23
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
24
|
+
throw new Error(options.interruptMessage ?? "Operation was aborted.");
|
|
25
|
+
}
|
|
26
|
+
const [first] = Cause.prettyErrors(exit.cause);
|
|
27
|
+
throw new Error(first?.message ?? Cause.pretty(exit.cause));
|
|
28
|
+
}
|