@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.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. 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 fallback in this repo's `bin/` directory (silently), and only
7
- * when neither exists is an official release downloaded into `bin/` — the
8
- * single case that shows a UI notification. Tools await that initialization
9
- * before executing, and report a clear error if it failed.
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
- repositoryBinDir,
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 ${repositoryBinDir()}`,
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 = repositoryBinDir();
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 fallback in this repository's `bin/` directory — used silently.
7
- * 3. A fresh download of an official release into `bin/` the only case that
8
- * should surface a UI notification.
6
+ * 2. An existing binary in the agent's managed bin directory
7
+ * (`~/.pi/agent/bin`), cached by an earlier downloadused 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 { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
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" | "bundled" | "installed";
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 repo bin directory. */
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
- /** Repository root (`~/.pi/agent`) resolved from this module's location. */
148
- export function repositoryBinDir() {
149
- const moduleDir = dirname(fileURLToPath(import.meta.url));
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, existing bin fallback, or fresh install. */
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 bundled = join(binDir, spec.binaryName);
197
- if (yield* env.probe(bundled, spec.tool)) {
198
- return { tool: spec.tool, command: bundled, source: "bundled" as const };
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, bundled);
222
+ yield* env.install(asset, cached);
209
223
 
210
- if (!(yield* env.probe(bundled, spec.tool))) {
224
+ if (!(yield* env.probe(cached, spec.tool))) {
211
225
  return yield* new InstallError({
212
- message: `${spec.tool} ${asset.version} was installed to ${bundled} but failed to run.`,
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: bundled,
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* Effect.fail(
241
- new Error(`download exceeds the ${maxBytes}-byte size limit`),
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 Error(`download exceeds the ${maxBytes}-byte size limit`),
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* Effect.fail(
276
- new Error(`refusing non-HTTPS download URL: ${url.href}`),
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* Effect.fail(
287
- new Error(`redirect from ${url.href} had no location header`),
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* Effect.fail(
292
- new Error(
293
- `download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
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* Effect.fail(
299
- new Error(
300
- `download returned an invalid redirect URL: ${location}`,
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* Effect.fail(
309
- new Error(`download failed with HTTP ${response.status}`),
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* Effect.fail(new Error("download redirect handling failed"));
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* Effect.fail(
348
- new Error(`invalid download URL: ${asset.url}`),
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* Effect.fail(
364
- new Error(
365
- `SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
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* Effect.fail(
388
- new Error(`tar failed with exit code ${tarExitCode}`),
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
- return install.pipe(
405
- Effect.scoped,
406
- Effect.provide(NodeServices.layer),
407
- Effect.provide(NodeHttpClient.layerFetch),
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 - bodyHeight(),
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
- "borderAccent",
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(bodyHeight() / 2)));
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(bodyHeight() / 2)));
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 - bodyHeight(),
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 = bodyHeight();
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
- const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"} · ${focus === "files" ? "FILES" : "DIFF"}`;
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
- 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));
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