@tt-a1i/openpi 0.3.1 → 0.5.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 +184 -59
- package/SETUP.md +23 -7
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +145 -0
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/index.ts +30 -2
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +486 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +14 -6
- package/extensions/background-terminals/src/result-delivery.ts +4 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +30 -42
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/schedule.ts +7 -1
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +146 -87
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +75 -59
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +328 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/model-info/index.ts +21 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +7 -2
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +222 -140
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +127 -131
- package/extensions/shared/activity-status.ts +36 -5
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +444 -22
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +97 -38
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +113 -0
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +42 -8
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +461 -186
- package/extensions/subagents/navigation.ts +86 -28
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +375 -66
- package/extensions/subagents/src/domain.ts +5 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +651 -536
- package/extensions/subagents/src/prompt.ts +185 -42
- package/extensions/subagents/src/result-artifact.ts +146 -0
- package/extensions/subagents/src/result-delivery.ts +7 -1
- package/extensions/subagents/src/runtime.ts +23 -6
- package/extensions/subagents/src/ui/takeover.ts +128 -337
- package/extensions/subagents/src/ui/transcript.ts +38 -501
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -44
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/web/index.ts +234 -0
- package/extensions/workflows/artifacts.ts +147 -22
- package/extensions/workflows/completion-projection.ts +457 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +62 -0
- package/extensions/workflows/dashboard.ts +458 -339
- package/extensions/workflows/handoff.ts +121 -25
- package/extensions/workflows/index.ts +1042 -492
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +131 -19
- package/extensions/workflows/navigation.ts +61 -18
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +166 -10
- package/extensions/workflows/replay-safety.ts +58 -27
- package/extensions/workflows/result-delivery.ts +253 -0
- package/extensions/workflows/retention.ts +593 -0
- package/extensions/workflows/runner.ts +388 -279
- package/extensions/workflows/sandbox-child.cjs +36 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +28 -8
- package/skills/subagents/REFERENCE.md +189 -0
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +10 -5
- package/skills/workflows/SKILL.md +53 -10
- package/web/adapter/pi-adapter.ts +661 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +789 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +462 -0
- package/web/runtime/pi-runtime.ts +991 -0
- package/web/runtime/types.ts +71 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1398 -0
- package/web/ui/index.html +139 -0
- package/web/ui/styles.css +598 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- package/extensions/subagents/src/backends/stub.ts +0 -296
- package/extensions/subagents/src/format.ts +0 -48
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* On session start the extension resolves a usable binary for each tool:
|
|
5
5
|
* a normally installed system binary is preferred (silently), then an
|
|
6
|
-
* existing
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* existing binary in the agent's managed bin directory (`~/.pi/agent/bin`,
|
|
7
|
+
* silently — e.g. cached by an earlier download), and only when neither
|
|
8
|
+
* exists is an official release downloaded into that directory — the single
|
|
9
|
+
* case that shows a UI notification. Tools await that initialization before
|
|
10
|
+
* executing, and report a clear error if it failed.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
import {
|
|
34
35
|
currentTarget,
|
|
35
36
|
liveBinaryEnv,
|
|
36
|
-
|
|
37
|
+
managedBinDir,
|
|
37
38
|
resolveBinary,
|
|
38
39
|
TOOL_SPECS,
|
|
39
40
|
type BinaryEnv,
|
|
@@ -80,7 +81,7 @@ export function installNotifications(binaries: readonly ResolvedBinary[]) {
|
|
|
80
81
|
.map(
|
|
81
82
|
(binary) =>
|
|
82
83
|
`file-search: no system ${binary.tool} found — downloaded ${binary.tool} ${binary.version ?? ""}`.trimEnd() +
|
|
83
|
-
` to ${
|
|
84
|
+
` to ${managedBinDir()}`,
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -136,7 +137,7 @@ export default function fileSearchTools(pi: ExtensionAPI) {
|
|
|
136
137
|
}
|
|
137
138
|
};
|
|
138
139
|
|
|
139
|
-
const binDir =
|
|
140
|
+
const binDir = managedBinDir();
|
|
140
141
|
const target = currentTarget();
|
|
141
142
|
const initializers = makeBinaryInitializers(binDir, target, liveBinaryEnv);
|
|
142
143
|
|
|
@@ -3,23 +3,32 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Resolution order (per tool, first usable wins):
|
|
5
5
|
* 1. A normally installed system binary (`fd`/`fdfind`, `rg`) — used silently.
|
|
6
|
-
* 2. An existing
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 2. An existing binary in the agent's managed bin directory
|
|
7
|
+
* (`~/.pi/agent/bin`), cached by an earlier download — used silently.
|
|
8
|
+
* 3. A fresh download of an official release into that directory — the only
|
|
9
|
+
* case that should surface a UI notification.
|
|
9
10
|
*
|
|
10
11
|
* The decision logic is an Effect over a small injectable environment
|
|
11
12
|
* (`BinaryEnv`) so tests can drive it without touching the filesystem or the
|
|
12
13
|
* network. `liveBinaryEnv` is the real implementation.
|
|
13
14
|
*/
|
|
14
15
|
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
|
|
16
18
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
17
19
|
import { execFile } from "node:child_process";
|
|
18
20
|
import { tmpdir } from "node:os";
|
|
19
21
|
import { dirname, join } from "node:path";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
22
|
import { promisify } from "node:util";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
Crypto,
|
|
25
|
+
Data,
|
|
26
|
+
Effect,
|
|
27
|
+
Encoding,
|
|
28
|
+
FileSystem,
|
|
29
|
+
Layer,
|
|
30
|
+
Stream,
|
|
31
|
+
} from "effect";
|
|
23
32
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http";
|
|
24
33
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
25
34
|
|
|
@@ -56,13 +65,13 @@ const RG_SHA256: Readonly<Record<string, string>> = {
|
|
|
56
65
|
};
|
|
57
66
|
|
|
58
67
|
export type ToolName = "fd" | "rg";
|
|
59
|
-
export type BinarySource = "system" | "
|
|
68
|
+
export type BinarySource = "system" | "cached" | "installed";
|
|
60
69
|
|
|
61
70
|
export interface ToolSpec {
|
|
62
71
|
readonly tool: ToolName;
|
|
63
72
|
/** Commands probed on PATH, in order. Debian/Ubuntu install fd as `fdfind`. */
|
|
64
73
|
readonly systemCommands: readonly string[];
|
|
65
|
-
/** Executable name used inside release archives and the
|
|
74
|
+
/** Executable name used inside release archives and the managed bin directory. */
|
|
66
75
|
readonly binaryName: string;
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -144,10 +153,9 @@ export function currentTarget(): PlatformTarget {
|
|
|
144
153
|
return { os: process.platform, arch: process.arch };
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
/**
|
|
148
|
-
export function
|
|
149
|
-
|
|
150
|
-
return join(moduleDir, "..", "..", "..", "bin");
|
|
156
|
+
/** Pi agent's managed binaries directory (`~/.pi/agent/bin`). */
|
|
157
|
+
export function managedBinDir() {
|
|
158
|
+
return join(getAgentDir(), "bin");
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
export class UnsupportedPlatformError extends Data.TaggedError(
|
|
@@ -161,6 +169,12 @@ export class InstallError extends Data.TaggedError("InstallError")<{
|
|
|
161
169
|
readonly cause?: unknown;
|
|
162
170
|
}> {}
|
|
163
171
|
|
|
172
|
+
export class BinaryDownloadError extends Data.TaggedError(
|
|
173
|
+
"BinaryDownloadError",
|
|
174
|
+
)<{
|
|
175
|
+
readonly message: string;
|
|
176
|
+
}> {}
|
|
177
|
+
|
|
164
178
|
export interface BinaryEnv {
|
|
165
179
|
/** True when the executable runs and supports the flags this tool requires. */
|
|
166
180
|
readonly probe: (command: string, tool: ToolName) => Effect.Effect<boolean>;
|
|
@@ -179,7 +193,7 @@ export interface ResolvedBinary {
|
|
|
179
193
|
readonly version?: string;
|
|
180
194
|
}
|
|
181
195
|
|
|
182
|
-
/** Resolve one tool: system binary,
|
|
196
|
+
/** Resolve one tool: system binary, cached fallback, or fresh install. */
|
|
183
197
|
export function resolveBinary(
|
|
184
198
|
spec: ToolSpec,
|
|
185
199
|
binDir: string,
|
|
@@ -193,9 +207,9 @@ export function resolveBinary(
|
|
|
193
207
|
}
|
|
194
208
|
}
|
|
195
209
|
|
|
196
|
-
const
|
|
197
|
-
if (yield* env.probe(
|
|
198
|
-
return { tool: spec.tool, command:
|
|
210
|
+
const cached = join(binDir, spec.binaryName);
|
|
211
|
+
if (yield* env.probe(cached, spec.tool)) {
|
|
212
|
+
return { tool: spec.tool, command: cached, source: "cached" as const };
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
const asset = releaseAsset(spec.tool, target);
|
|
@@ -205,17 +219,17 @@ export function resolveBinary(
|
|
|
205
219
|
});
|
|
206
220
|
}
|
|
207
221
|
|
|
208
|
-
yield* env.install(asset,
|
|
222
|
+
yield* env.install(asset, cached);
|
|
209
223
|
|
|
210
|
-
if (!(yield* env.probe(
|
|
224
|
+
if (!(yield* env.probe(cached, spec.tool))) {
|
|
211
225
|
return yield* new InstallError({
|
|
212
|
-
message: `${spec.tool} ${asset.version} was installed to ${
|
|
226
|
+
message: `${spec.tool} ${asset.version} was installed to ${cached} but failed to run.`,
|
|
213
227
|
});
|
|
214
228
|
}
|
|
215
229
|
|
|
216
230
|
return {
|
|
217
231
|
tool: spec.tool,
|
|
218
|
-
command:
|
|
232
|
+
command: cached,
|
|
219
233
|
source: "installed" as const,
|
|
220
234
|
version: asset.version,
|
|
221
235
|
};
|
|
@@ -237,9 +251,9 @@ export function readBoundedResponse<E, R>(
|
|
|
237
251
|
return Effect.gen(function* () {
|
|
238
252
|
const declaredLength = Number(response.headers["content-length"]);
|
|
239
253
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
240
|
-
return yield*
|
|
241
|
-
|
|
242
|
-
);
|
|
254
|
+
return yield* new BinaryDownloadError({
|
|
255
|
+
message: `download exceeds the ${maxBytes}-byte size limit`,
|
|
256
|
+
});
|
|
243
257
|
}
|
|
244
258
|
|
|
245
259
|
const result = yield* Stream.runFoldEffect(
|
|
@@ -249,7 +263,9 @@ export function readBoundedResponse<E, R>(
|
|
|
249
263
|
const totalBytes = accumulator.totalBytes + chunk.byteLength;
|
|
250
264
|
if (totalBytes > maxBytes) {
|
|
251
265
|
return Effect.fail(
|
|
252
|
-
new
|
|
266
|
+
new BinaryDownloadError({
|
|
267
|
+
message: `download exceeds the ${maxBytes}-byte size limit`,
|
|
268
|
+
}),
|
|
253
269
|
);
|
|
254
270
|
}
|
|
255
271
|
return Effect.sync(() => {
|
|
@@ -272,9 +288,9 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
|
|
|
272
288
|
|
|
273
289
|
for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) {
|
|
274
290
|
if (url.protocol !== "https:") {
|
|
275
|
-
return yield*
|
|
276
|
-
|
|
277
|
-
);
|
|
291
|
+
return yield* new BinaryDownloadError({
|
|
292
|
+
message: `refusing non-HTTPS download URL: ${url.href}`,
|
|
293
|
+
});
|
|
278
294
|
}
|
|
279
295
|
|
|
280
296
|
const result = yield* Effect.scoped(
|
|
@@ -283,31 +299,27 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
|
|
|
283
299
|
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
284
300
|
const location = response.headers.location;
|
|
285
301
|
if (!location) {
|
|
286
|
-
return yield*
|
|
287
|
-
|
|
288
|
-
);
|
|
302
|
+
return yield* new BinaryDownloadError({
|
|
303
|
+
message: `redirect from ${url.href} had no location header`,
|
|
304
|
+
});
|
|
289
305
|
}
|
|
290
306
|
if (redirects === MAX_DOWNLOAD_REDIRECTS) {
|
|
291
|
-
return yield*
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
),
|
|
295
|
-
);
|
|
307
|
+
return yield* new BinaryDownloadError({
|
|
308
|
+
message: `download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
|
|
309
|
+
});
|
|
296
310
|
}
|
|
297
311
|
if (!URL.canParse(location, url)) {
|
|
298
|
-
return yield*
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
),
|
|
302
|
-
);
|
|
312
|
+
return yield* new BinaryDownloadError({
|
|
313
|
+
message: `download returned an invalid redirect URL: ${location}`,
|
|
314
|
+
});
|
|
303
315
|
}
|
|
304
316
|
return { _tag: "Redirect" as const, url: new URL(location, url) };
|
|
305
317
|
}
|
|
306
318
|
|
|
307
319
|
if (response.status < 200 || response.status >= 300) {
|
|
308
|
-
return yield*
|
|
309
|
-
|
|
310
|
-
);
|
|
320
|
+
return yield* new BinaryDownloadError({
|
|
321
|
+
message: `download failed with HTTP ${response.status}`,
|
|
322
|
+
});
|
|
311
323
|
}
|
|
312
324
|
return {
|
|
313
325
|
_tag: "Complete" as const,
|
|
@@ -320,7 +332,9 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
|
|
|
320
332
|
url = result.url;
|
|
321
333
|
}
|
|
322
334
|
|
|
323
|
-
return yield*
|
|
335
|
+
return yield* new BinaryDownloadError({
|
|
336
|
+
message: "download redirect handling failed",
|
|
337
|
+
});
|
|
324
338
|
});
|
|
325
339
|
}
|
|
326
340
|
|
|
@@ -344,9 +358,9 @@ export const liveBinaryEnv: BinaryEnv = {
|
|
|
344
358
|
install: (asset, destination) => {
|
|
345
359
|
const install = Effect.gen(function* () {
|
|
346
360
|
if (!URL.canParse(asset.url)) {
|
|
347
|
-
return yield*
|
|
348
|
-
|
|
349
|
-
);
|
|
361
|
+
return yield* new BinaryDownloadError({
|
|
362
|
+
message: `invalid download URL: ${asset.url}`,
|
|
363
|
+
});
|
|
350
364
|
}
|
|
351
365
|
|
|
352
366
|
const url = new URL(asset.url);
|
|
@@ -360,11 +374,9 @@ export const liveBinaryEnv: BinaryEnv = {
|
|
|
360
374
|
const digestBytes = yield* crypto.digest("SHA-256", bytes);
|
|
361
375
|
const digest = Encoding.encodeHex(digestBytes);
|
|
362
376
|
if (digest !== asset.sha256) {
|
|
363
|
-
return yield*
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
),
|
|
367
|
-
);
|
|
377
|
+
return yield* new BinaryDownloadError({
|
|
378
|
+
message: `SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
|
|
379
|
+
});
|
|
368
380
|
}
|
|
369
381
|
|
|
370
382
|
const workDir = yield* fs.makeTempDirectoryScoped({
|
|
@@ -384,9 +396,9 @@ export const liveBinaryEnv: BinaryEnv = {
|
|
|
384
396
|
}),
|
|
385
397
|
).pipe(Effect.timeout(60_000));
|
|
386
398
|
if (tarExitCode !== ChildProcessSpawner.ExitCode(0)) {
|
|
387
|
-
return yield*
|
|
388
|
-
|
|
389
|
-
);
|
|
399
|
+
return yield* new BinaryDownloadError({
|
|
400
|
+
message: `tar failed with exit code ${tarExitCode}`,
|
|
401
|
+
});
|
|
390
402
|
}
|
|
391
403
|
|
|
392
404
|
const extracted = join(workDir, asset.archiveDir, asset.binaryName);
|
|
@@ -401,13 +413,17 @@ export const liveBinaryEnv: BinaryEnv = {
|
|
|
401
413
|
yield* fs.rename(stagedDestination, destination);
|
|
402
414
|
});
|
|
403
415
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
Effect.provideService(FetchHttpClient.RequestInit, {
|
|
416
|
+
const InstallLayer = Layer.mergeAll(
|
|
417
|
+
NodeServices.layer,
|
|
418
|
+
NodeHttpClient.layerFetch,
|
|
419
|
+
Layer.succeed(FetchHttpClient.RequestInit, {
|
|
409
420
|
redirect: "manual",
|
|
410
421
|
}),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
return install.pipe(
|
|
425
|
+
Effect.scoped,
|
|
426
|
+
Effect.provide(InstallLayer),
|
|
411
427
|
Effect.mapError(
|
|
412
428
|
(cause) =>
|
|
413
429
|
new InstallError({
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
visibleWidth,
|
|
8
8
|
} from "@earendil-works/pi-tui";
|
|
9
9
|
import { Effect } from "effect";
|
|
10
|
+
import { hintLine } from "../../shared/screen-chrome.ts";
|
|
10
11
|
import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
|
|
11
12
|
import { runCommand } from "./process.ts";
|
|
12
13
|
|
|
@@ -174,6 +175,15 @@ export async function showChangedFiles(
|
|
|
174
175
|
return Math.max(8, Math.floor(tui.terminal.rows * 0.8) - 2);
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Rows the diff pane actually paints: one row of the frame's budget goes
|
|
180
|
+
* to the hint line below it. Scroll limits must use this and not
|
|
181
|
+
* bodyHeight(), or the last diff line can never be scrolled into view.
|
|
182
|
+
*/
|
|
183
|
+
function visibleRows() {
|
|
184
|
+
return Math.max(4, bodyHeight() - 1);
|
|
185
|
+
}
|
|
186
|
+
|
|
177
187
|
function ensureSelectedFileVisible() {
|
|
178
188
|
const visibleFiles = Math.max(1, Math.floor(bodyHeight() / 2));
|
|
179
189
|
if (selectedIndex < sidebarOffset) sidebarOffset = selectedIndex;
|
|
@@ -192,7 +202,7 @@ export async function showChangedFiles(
|
|
|
192
202
|
function moveDiff(amount: number) {
|
|
193
203
|
const maxOffset = Math.max(
|
|
194
204
|
0,
|
|
195
|
-
files[selectedIndex]!.diff.length -
|
|
205
|
+
files[selectedIndex]!.diff.length - visibleRows(),
|
|
196
206
|
);
|
|
197
207
|
diffOffset = Math.max(0, Math.min(maxOffset, diffOffset + amount));
|
|
198
208
|
tui.requestRender();
|
|
@@ -216,13 +226,18 @@ export async function showChangedFiles(
|
|
|
216
226
|
return theme.fg("text", expanded);
|
|
217
227
|
}
|
|
218
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Frame edge with an optional label set into it. Muted, not accent: the
|
|
231
|
+
* frame is not the content, and the accent is spent on the focused pane
|
|
232
|
+
* seam where it actually tells you something.
|
|
233
|
+
*/
|
|
219
234
|
function border(width: number, label: string, top: boolean) {
|
|
220
235
|
const left = top ? "┌" : "└";
|
|
221
236
|
const right = top ? "┐" : "┘";
|
|
222
|
-
const text = `─ ${label}
|
|
237
|
+
const text = label ? `─ ${label} ` : "─";
|
|
223
238
|
const remaining = Math.max(0, width - visibleWidth(text) - 2);
|
|
224
239
|
return theme.fg(
|
|
225
|
-
"
|
|
240
|
+
"borderMuted",
|
|
226
241
|
truncateToWidth(
|
|
227
242
|
`${left}${text}${"─".repeat(remaining)}${right}`,
|
|
228
243
|
width,
|
|
@@ -289,11 +304,11 @@ export async function showChangedFiles(
|
|
|
289
304
|
return;
|
|
290
305
|
}
|
|
291
306
|
if (matchesKey(data, Key.ctrl("d"))) {
|
|
292
|
-
moveDiff(Math.max(1, Math.floor(
|
|
307
|
+
moveDiff(Math.max(1, Math.floor(visibleRows() / 2)));
|
|
293
308
|
return;
|
|
294
309
|
}
|
|
295
310
|
if (matchesKey(data, Key.ctrl("u"))) {
|
|
296
|
-
moveDiff(-Math.max(1, Math.floor(
|
|
311
|
+
moveDiff(-Math.max(1, Math.floor(visibleRows() / 2)));
|
|
297
312
|
return;
|
|
298
313
|
}
|
|
299
314
|
if (matchesKey(data, Key.home) || data === "g") {
|
|
@@ -304,21 +319,23 @@ export async function showChangedFiles(
|
|
|
304
319
|
if (matchesKey(data, Key.end) || data === "G") {
|
|
305
320
|
diffOffset = Math.max(
|
|
306
321
|
0,
|
|
307
|
-
files[selectedIndex]!.diff.length -
|
|
322
|
+
files[selectedIndex]!.diff.length - visibleRows(),
|
|
308
323
|
);
|
|
309
324
|
tui.requestRender();
|
|
310
325
|
}
|
|
311
326
|
}
|
|
312
327
|
|
|
313
328
|
function render(width: number) {
|
|
314
|
-
const height =
|
|
329
|
+
const height = visibleRows();
|
|
315
330
|
const sidebarWidth = Math.min(
|
|
316
331
|
48,
|
|
317
332
|
Math.max(24, Math.floor(width * 0.34)),
|
|
318
333
|
);
|
|
319
334
|
const diffWidth = Math.max(1, width - sidebarWidth - 3);
|
|
320
335
|
const selectedFile = files[selectedIndex]!;
|
|
321
|
-
|
|
336
|
+
// No shouted FILES/DIFF badge: the accent-lit pane seam already says
|
|
337
|
+
// where the keyboard is, without a second thing to read.
|
|
338
|
+
const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"}`;
|
|
322
339
|
const lines = [border(width, title, true)];
|
|
323
340
|
|
|
324
341
|
for (let row = 0; row < height; row += 1) {
|
|
@@ -329,7 +346,7 @@ export async function showChangedFiles(
|
|
|
329
346
|
if (file) {
|
|
330
347
|
const isSelected = fileIndex === selectedIndex;
|
|
331
348
|
if (row % 2 === 0) {
|
|
332
|
-
const marker = isSelected ? "
|
|
349
|
+
const marker = isSelected ? "❯ " : " ";
|
|
333
350
|
const isBinary =
|
|
334
351
|
file.additions === null || file.deletions === null;
|
|
335
352
|
const stats = isBinary
|
|
@@ -386,11 +403,27 @@ export async function showChangedFiles(
|
|
|
386
403
|
);
|
|
387
404
|
}
|
|
388
405
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
406
|
+
lines.push(border(width, "", false));
|
|
407
|
+
// Hints below the frame rather than crammed into its bottom edge, with
|
|
408
|
+
// keys a step brighter than the words describing them.
|
|
409
|
+
lines.push(
|
|
410
|
+
hintLine(
|
|
411
|
+
theme,
|
|
412
|
+
focus === "files"
|
|
413
|
+
? [
|
|
414
|
+
["j/k or ↑/↓", "select"],
|
|
415
|
+
["enter/space/l", "open diff"],
|
|
416
|
+
["esc", "close"],
|
|
417
|
+
]
|
|
418
|
+
: [
|
|
419
|
+
["j/k or ↑/↓", "scroll"],
|
|
420
|
+
["ctrl-d/u", "page"],
|
|
421
|
+
["g/G", "top/bottom"],
|
|
422
|
+
["esc/h", "files"],
|
|
423
|
+
],
|
|
424
|
+
width,
|
|
425
|
+
),
|
|
426
|
+
);
|
|
394
427
|
return lines;
|
|
395
428
|
}
|
|
396
429
|
|