@tt-a1i/openpi 0.3.1 → 0.4.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 (60) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +30 -42
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +204 -140
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/runtime.ts +8 -5
  38. package/extensions/subagents/src/ui/takeover.ts +84 -109
  39. package/extensions/subagents/src/ui/transcript.ts +76 -42
  40. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  41. package/extensions/tasks/ui.ts +79 -62
  42. package/extensions/ui-customization/footer.ts +7 -4
  43. package/extensions/user-input-fold/index.ts +185 -0
  44. package/extensions/workflows/artifacts.ts +35 -0
  45. package/extensions/workflows/controller.ts +14 -2
  46. package/extensions/workflows/coordinator.ts +64 -0
  47. package/extensions/workflows/dashboard.ts +353 -173
  48. package/extensions/workflows/handoff.ts +62 -20
  49. package/extensions/workflows/index.ts +647 -387
  50. package/extensions/workflows/model.ts +57 -15
  51. package/extensions/workflows/navigation.ts +33 -14
  52. package/extensions/workflows/prompt.ts +104 -8
  53. package/extensions/workflows/replay-safety.ts +16 -6
  54. package/extensions/workflows/result-delivery.ts +189 -0
  55. package/extensions/workflows/sandbox-child.cjs +11 -0
  56. package/package.json +1 -1
  57. package/skills/subagents/SKILL.md +2 -2
  58. package/skills/workflows/REFERENCE.md +7 -4
  59. package/skills/workflows/SKILL.md +53 -10
  60. package/extensions/subagents/src/format.ts +0 -48
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Read-only git commands: pure argv construction.
3
+ *
4
+ * Everything here is synchronous and side-effect free so the exact argv
5
+ * passed to the child process can be asserted in tests. Revision and path
6
+ * inputs are validated against strict shapes before they are ever placed
7
+ * in an argument list, and every user-controlled value goes after a `--`
8
+ * separator so it can never be parsed as a flag. Only argv subcommands that
9
+ * cannot write are reachable at all.
10
+ */
11
+
12
+ export const GIT_TIMEOUT_MS = 10_000;
13
+ export const GIT_LOG_DEFAULT_LIMIT = 100;
14
+ export const GIT_LOG_MAX_LIMIT = 1000;
15
+
16
+ /** Revisions may be a sha-ish hex string, HEAD~n, HEAD^n, or a plain name; a
17
+ * leading `-` is rejected so a revision can never smuggle a git flag. */
18
+ const REVISION_PATTERN =
19
+ /^(?:[0-9a-fA-F]{4,40}|HEAD(?:[~^]\d*)*|[A-Za-z_][\w./@-]{0,199})$/;
20
+ /** Diffs compare two revisions; worktree comparison uses one revision. */
21
+ const MAX_REVISION_LENGTH = 200;
22
+
23
+ export function isSafeRevision(revision: string): boolean {
24
+ const trimmed = revision.trim();
25
+ if (trimmed.length === 0 || trimmed.length > MAX_REVISION_LENGTH)
26
+ return false;
27
+ return REVISION_PATTERN.test(trimmed);
28
+ }
29
+
30
+ /** A relative repo path; `..` escapes and absolute paths are rejected. */
31
+ const REPO_PATH_PATTERN = /^[^:/\\?#*[\]'"\s][^:/\\?#*[\]'"\s]*$/;
32
+
33
+ export function isSafeRepoPath(value: string): boolean {
34
+ if (value.length === 0 || value.length > 512) return false;
35
+ const segments = value.split("/");
36
+ for (const segment of segments) {
37
+ if (segment === "" || segment === "." || segment === "..") return false;
38
+ if (!REPO_PATH_PATTERN.test(segment)) return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ function revision(value: string): string | undefined {
44
+ return isSafeRevision(value) ? value.trim() : undefined;
45
+ }
46
+
47
+ export class InvalidRevisionError extends Error {
48
+ constructor(value: string) {
49
+ super(
50
+ `Invalid git revision: ${JSON.stringify(value.slice(0, 80))}. Allowed: a commit sha, branch or tag name, or HEAD with ~ / ^ modifiers.`,
51
+ );
52
+ this.name = "InvalidRevisionError";
53
+ }
54
+ }
55
+
56
+ export class InvalidPathError extends Error {
57
+ constructor(value: string) {
58
+ super(
59
+ `Invalid repository path: ${JSON.stringify(value.slice(0, 80))}. Use a relative path inside the repository.`,
60
+ );
61
+ this.name = "InvalidPathError";
62
+ }
63
+ }
64
+
65
+ export class InvalidDiffCombinationError extends Error {
66
+ constructor(message: string) {
67
+ super(`Invalid git diff options: ${message}`);
68
+ this.name = "InvalidDiffCombinationError";
69
+ }
70
+ }
71
+
72
+ export interface GitShowParams {
73
+ revision: string;
74
+ path?: string;
75
+ }
76
+
77
+ export function buildShowArgs(params: GitShowParams): string[] {
78
+ if (!isSafeRevision(params.revision))
79
+ throw new InvalidRevisionError(params.revision);
80
+ const args = [
81
+ "show",
82
+ "--no-color",
83
+ "--no-ext-diff",
84
+ "--no-textconv",
85
+ "--format=fuller",
86
+ params.revision.trim(),
87
+ ];
88
+ if (params.path !== undefined) {
89
+ if (!isSafeRepoPath(params.path)) throw new InvalidPathError(params.path);
90
+ args.push("--", params.path);
91
+ }
92
+ return args;
93
+ }
94
+
95
+ export interface GitDiffParams {
96
+ /** Base revision, e.g. HEAD, HEAD~1, a branch name, or a sha. */
97
+ from?: string;
98
+ /** Compared revision; omit to compare `from` against the worktree. */
99
+ to?: string;
100
+ /** Compare against the index (staged changes) instead of the worktree. */
101
+ staged?: boolean;
102
+ stat?: boolean;
103
+ path?: string;
104
+ }
105
+
106
+ export function buildDiffArgs(params: GitDiffParams): string[] {
107
+ const from = params.from !== undefined ? revision(params.from) : undefined;
108
+ if (params.from !== undefined && from === undefined) {
109
+ throw new InvalidRevisionError(params.from);
110
+ }
111
+ const to = params.to !== undefined ? revision(params.to) : undefined;
112
+ if (params.to !== undefined && to === undefined) {
113
+ throw new InvalidRevisionError(params.to);
114
+ }
115
+ if (params.to !== undefined && params.from === undefined) {
116
+ throw new InvalidDiffCombinationError("to requires from");
117
+ }
118
+ if (params.staged && (params.from !== undefined || params.to !== undefined)) {
119
+ throw new InvalidDiffCombinationError(
120
+ "staged cannot be combined with from or to",
121
+ );
122
+ }
123
+
124
+ const args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"];
125
+ if (params.staged) args.push("--cached");
126
+ if (params.stat) args.push("--stat");
127
+
128
+ if (from !== undefined && to !== undefined) {
129
+ // An explicit `--` separator is unnecessary for the range form; a
130
+ // validated revision can never start with `-` anyway.
131
+ args.push(`${from}...${to}`);
132
+ } else if (from !== undefined) {
133
+ args.push(from);
134
+ }
135
+ // No revisions: worktree vs index (or HEAD with --cached).
136
+
137
+ if (params.path !== undefined) {
138
+ if (!isSafeRepoPath(params.path)) throw new InvalidPathError(params.path);
139
+ args.push("--", params.path);
140
+ }
141
+ return args;
142
+ }
143
+
144
+ export interface GitLogParams {
145
+ revision?: string;
146
+ file?: string;
147
+ limit?: number;
148
+ oneline?: boolean;
149
+ }
150
+
151
+ export function buildLogArgs(params: GitLogParams): string[] {
152
+ const args = ["log", "--no-color", "--no-ext-diff"];
153
+ if (params.oneline !== false) args.push("--oneline");
154
+ const limit = Math.min(
155
+ GIT_LOG_MAX_LIMIT,
156
+ Math.max(1, Math.floor(params.limit ?? GIT_LOG_DEFAULT_LIMIT)),
157
+ );
158
+ args.push(`-n`, String(limit));
159
+
160
+ if (params.revision !== undefined) {
161
+ if (!isSafeRevision(params.revision)) {
162
+ throw new InvalidRevisionError(params.revision);
163
+ }
164
+ args.push(params.revision.trim());
165
+ }
166
+ if (params.file !== undefined) {
167
+ if (!isSafeRepoPath(params.file)) throw new InvalidPathError(params.file);
168
+ args.push("--", params.file);
169
+ }
170
+ return args;
171
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Bounded git process execution for the read-only git tools.
3
+ *
4
+ * Reuses the file-search capture discipline: a preview is retained in memory
5
+ * under pi's standard truncation limits while the complete output (up to the
6
+ * 10 MiB capture cap) streams to a temporary file, and the process group is
7
+ * terminated when the cap is exceeded.
8
+ */
9
+
10
+ import * as NodeServices from "@effect/platform-node/NodeServices";
11
+ import { Data, Effect } from "effect";
12
+ import type { CapturedOutput } from "../../file-search/src/output.ts";
13
+ import {
14
+ discardCapturedOutput,
15
+ executeSearchProcess,
16
+ } from "../../file-search/src/process.ts";
17
+ import { GIT_TIMEOUT_MS } from "./args.ts";
18
+
19
+ export const GIT_CAPTURE_MAX_BYTES = 10 * 1024 * 1024;
20
+
21
+ export class GitCommandError extends Data.TaggedError("GitCommandError")<{
22
+ readonly message: string;
23
+ }> {}
24
+
25
+ export interface GitOutcome {
26
+ readonly output: CapturedOutput;
27
+ readonly exitCode: number;
28
+ }
29
+
30
+ /**
31
+ * Run one git subcommand. Exit code 128 (not a repository / bad revision) and
32
+ * 129 (usage) surface as a GitCommandError carrying stderr; other non-zero
33
+ * exits also fail with the captured stderr detail.
34
+ */
35
+ export function runGit(
36
+ args: readonly string[],
37
+ cwd: string,
38
+ timeoutMs = GIT_TIMEOUT_MS,
39
+ ): Effect.Effect<GitOutcome, GitCommandError> {
40
+ return Effect.gen(function* () {
41
+ const result = yield* Effect.catchTags(
42
+ executeSearchProcess({
43
+ command: "git",
44
+ args,
45
+ cwd,
46
+ tempPrefix: "pi-git-",
47
+ maxCaptureBytes: GIT_CAPTURE_MAX_BYTES,
48
+ }),
49
+ {
50
+ SearchProcessError: (error) =>
51
+ new GitCommandError({
52
+ message: `failed to run git: ${error.message}`,
53
+ }),
54
+ PlatformError: (error) =>
55
+ new GitCommandError({
56
+ message: `git output capture failed: ${String(error.reason ?? "")}`,
57
+ }),
58
+ },
59
+ );
60
+ if (result.code !== 0) {
61
+ const detail =
62
+ result.stderr.trim() || `git exited with code ${result.code}`;
63
+ // A failure means nothing useful was captured; drop the artifact.
64
+ yield* Effect.ignore(discardCapturedOutput(result.output));
65
+ return yield* new GitCommandError({ message: detail });
66
+ }
67
+ return {
68
+ output: result.output,
69
+ exitCode: result.code,
70
+ } satisfies GitOutcome;
71
+ }).pipe(
72
+ Effect.timeout(timeoutMs),
73
+ Effect.mapError((error) => {
74
+ if (error._tag === "GitCommandError") return error;
75
+ return new GitCommandError({
76
+ message: `git command timed out after ${timeoutMs}ms`,
77
+ });
78
+ }),
79
+ Effect.provide(NodeServices.layer),
80
+ );
81
+ }
@@ -0,0 +1,56 @@
1
+ /** Model-facing text for the read-only git tools. */
2
+
3
+ export const GIT_SHOW_TOOL_DESCRIPTION =
4
+ "Show a git commit (message, author, and patch), optionally limited to one repository path. Read-only.";
5
+
6
+ export const GIT_SHOW_PROMPT_SNIPPET =
7
+ "Inspect a specific git commit with git_show.";
8
+
9
+ export const GIT_SHOW_PROMPT_GUIDELINES = [
10
+ "Use git_show to review what a single commit changed instead of reading the whole file tree and guessing.",
11
+ "Use git_diff to compare revisions or the worktree, and git_log to find commits first.",
12
+ ];
13
+
14
+ export const GIT_SHOW_PARAMETER_DESCRIPTIONS = {
15
+ revision:
16
+ "Commit to show: a sha (>=4 hex chars), branch or tag name, or HEAD with ~ / ^ modifiers, e.g. HEAD, HEAD~2, main.",
17
+ path: "Optional relative path inside the repository. Limits the commit patch to this path; it does not read the file blob.",
18
+ };
19
+
20
+ export const GIT_DIFF_TOOL_DESCRIPTION =
21
+ "Show a git diff: between two revisions, a revision and the worktree, or staged changes. Read-only.";
22
+
23
+ export const GIT_DIFF_PROMPT_SNIPPET =
24
+ "Compare git revisions or working-tree changes with git_diff.";
25
+
26
+ export const GIT_DIFF_PROMPT_GUIDELINES = [
27
+ "Use git_diff (not git_show) when reviewing changes between refs or uncommitted work.",
28
+ "Set stat to true first for a broad overview, then drill into specific paths.",
29
+ "To review a pull request, diff its branch against the base, e.g. from: 'main', to: 'feature-branch'.",
30
+ ];
31
+
32
+ export const GIT_DIFF_PARAMETER_DESCRIPTIONS = {
33
+ from: "Base revision. With to, compares from...to from their merge base (PR-style). Omit both revisions to diff the worktree against the index.",
34
+ to: "Compared revision. Requires from and uses the merge-base range from...to. Give from alone to diff that revision against the worktree.",
35
+ staged:
36
+ "Compare the index (staged changes) against HEAD instead of the worktree.",
37
+ stat: "Show a diffstat (files and line counts) instead of the full patch.",
38
+ path: "Only diff this relative path inside the repository.",
39
+ };
40
+
41
+ export const GIT_LOG_TOOL_DESCRIPTION =
42
+ "List commit history with sha, author, date, and first line. Filter by revision or file. Read-only.";
43
+
44
+ export const GIT_LOG_PROMPT_SNIPPET = "Browse git history with git_log.";
45
+
46
+ export const GIT_LOG_PROMPT_GUIDELINES = [
47
+ "Use git_log with a file to find who last touched a piece of code before changing it.",
48
+ "Keep oneline true unless the full commit message of every entry is needed.",
49
+ ];
50
+
51
+ export const GIT_LOG_PARAMETER_DESCRIPTIONS = {
52
+ revision: "History starting from this revision. Defaults to HEAD.",
53
+ file: "Only commits touching this relative path.",
54
+ limit: "Maximum commits to list (1-1000). Defaults to 100.",
55
+ oneline: "One line per commit (default true); false adds full messages.",
56
+ };
@@ -5,22 +5,24 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  DynamicBorder,
8
- SessionManager,
9
8
  keyHint,
9
+ SessionManager,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import {
12
12
  CancellableLoader,
13
13
  Container,
14
14
  Key,
15
- SelectList,
15
+ matchesKey,
16
16
  type SelectItem,
17
+ SelectList,
17
18
  Spacer,
18
19
  Text,
19
- matchesKey,
20
20
  truncateToWidth,
21
21
  visibleWidth,
22
22
  wrapTextWithAnsi,
23
23
  } from "@earendil-works/pi-tui";
24
+ import { hintLine } from "../shared/screen-chrome.ts";
25
+ import { createSessionStatsLoader, type SessionStats } from "./git-stats.js";
24
26
  import {
25
27
  buildPreviewError,
26
28
  buildSessionDescription,
@@ -28,14 +30,13 @@ import {
28
30
  buildSessionPreview,
29
31
  buildSessionSearchEntries,
30
32
  filterSessionEntries,
33
+ formatRelativeTime,
31
34
  getSessionPaneLayout,
32
- parseLimit,
33
35
  type PreviewBlock,
36
+ parseLimit,
34
37
  type SessionInfoLike,
35
38
  type SessionPreview,
36
- formatRelativeTime,
37
39
  } from "./sessions.js";
38
- import { createSessionStatsLoader, type SessionStats } from "./git-stats.js";
39
40
 
40
41
  const DEFAULT_VISIBLE = 12;
41
42
  const SNIPPET_MAX = 60;
@@ -82,7 +83,7 @@ const themeText = (
82
83
  ): string => {
83
84
  if (kind === "title") return theme.fg("accent", theme.bold(text));
84
85
  if (kind === "subtitle") return theme.fg("dim", text);
85
- if (kind === "rule") return theme.fg("border", text);
86
+ if (kind === "rule") return theme.fg("borderMuted", text);
86
87
  if (kind === "user") return theme.fg("accent", theme.bold(text));
87
88
  if (kind === "assistant") return theme.fg("warning", theme.bold(text));
88
89
  if (kind === "tool") return theme.fg("muted", theme.bold(text));
@@ -357,31 +358,20 @@ const renderPreview = (
357
358
  ): { lines: string[]; totalLines: number; maxScroll: number } => {
358
359
  const raw: string[] = [];
359
360
 
361
+ // Left-aligned, like every other OpenPI heading: centred titles read as a
362
+ // different application than the panel they sit inside, and they wander as
363
+ // the pane resizes.
360
364
  if (!preview) {
361
- raw.push(
362
- " ".repeat(Math.max(0, Math.floor((width - 7) / 2))) +
363
- themeText(theme, "title", "Preview"),
364
- );
365
- raw.push(
366
- " ".repeat(Math.max(0, Math.floor((width - 25) / 2))) +
367
- themeText(theme, "subtitle", "Loading selected session…"),
368
- );
365
+ raw.push(themeText(theme, "title", "Preview"));
366
+ raw.push(themeText(theme, "subtitle", "Loading selected session…"));
369
367
  } else {
370
- const titleStr = "Thread Preview";
371
- const titleColor =
368
+ const titleStr = "Thread preview";
369
+ raw.push(
372
370
  focus === "preview"
373
371
  ? themeText(theme, "title", titleStr)
374
- : themeText(theme, "subtitle", titleStr);
375
- const titlePadding = " ".repeat(
376
- Math.max(0, Math.floor((width - titleStr.length) / 2)),
377
- );
378
- raw.push(`${titlePadding}${titleColor}`);
379
-
380
- const subStr = preview.subtitle;
381
- const subPadding = " ".repeat(
382
- Math.max(0, Math.floor((width - visibleWidth(subStr)) / 2)),
372
+ : themeText(theme, "subtitle", titleStr),
383
373
  );
384
- raw.push(`${subPadding}${themeText(theme, "subtitle", subStr)}`);
374
+ raw.push(themeText(theme, "subtitle", preview.subtitle));
385
375
 
386
376
  raw.push(themeText(theme, "rule", "─".repeat(Math.max(0, width))));
387
377
 
@@ -438,10 +428,10 @@ const renderPreview = (
438
428
  if (i >= thumbStart && i < thumbStart + thumbSize) {
439
429
  scrollChar = theme.fg("text", "█");
440
430
  } else {
441
- scrollChar = theme.fg("border", "│");
431
+ scrollChar = theme.fg("borderMuted", "│");
442
432
  }
443
433
  } else {
444
- scrollChar = theme.fg("border", "│");
434
+ scrollChar = theme.fg("borderMuted", "│");
445
435
  }
446
436
 
447
437
  visible.push(padAnsiRight(line, width - 1) + scrollChar);
@@ -474,7 +464,9 @@ async function listSessions(
474
464
  const sessions = await ctx.ui.custom<SessionInfoLike[] | null>(
475
465
  (tui, theme, _kb, done) => {
476
466
  const container = new Container();
477
- const borderColor = (text: string) => theme.fg("border", text);
467
+ // Same tone as the split-pane frame and every other OpenPI panel; this
468
+ // was the last `border` call left in the package.
469
+ const borderColor = (text: string) => theme.fg("borderMuted", text);
478
470
 
479
471
  const loader = new CancellableLoader(
480
472
  tui,
@@ -732,7 +724,7 @@ async function showSessionPicker(
732
724
  if (isLoading) {
733
725
  container.addChild(new Spacer(1));
734
726
  container.addChild(
735
- new Text(theme.fg("muted", " Loading sessions..."), 1, 0),
727
+ new Text(theme.fg("muted", " Loading sessions"), 1, 0),
736
728
  );
737
729
  container.addChild(new Spacer(1));
738
730
  } else {
@@ -762,7 +754,15 @@ async function showSessionPicker(
762
754
  }
763
755
  container.addChild(
764
756
  new Text(
765
- theme.fg("dim", "↑↓ navigate • enter open • esc cancel"),
757
+ hintLine(
758
+ theme,
759
+ [
760
+ ["↑↓", "navigate"],
761
+ ["enter", "open"],
762
+ ["esc", "cancel"],
763
+ ],
764
+ Math.max(1, width - 2),
765
+ ),
766
766
  1,
767
767
  0,
768
768
  ),
@@ -777,29 +777,29 @@ async function showSessionPicker(
777
777
  listWidth: number,
778
778
  previewWidth: number,
779
779
  ): string => {
780
- const leftTitle = " Switch Thread ";
780
+ const leftTitle = " sessions ";
781
+ const rightTitle = " preview ";
781
782
  const left = `┌─${leftTitle}${"─".repeat(Math.max(0, listWidth - leftTitle.length - 2))}`;
782
- const right = "─".repeat(Math.max(0, previewWidth - 1)) + "┐";
783
- return `${theme.fg("border", left)}${theme.fg("border", "─┬─")}${theme.fg("border", right)}`;
783
+ const right =
784
+ `─${rightTitle}${"".repeat(Math.max(0, previewWidth - rightTitle.length - 2))}` +
785
+ "┐";
786
+ return `${theme.fg("borderMuted", left)}${theme.fg("borderMuted", "─┬─")}${theme.fg("borderMuted", right)}`;
784
787
  };
785
788
 
786
789
  const buildBottomBorder = (
787
790
  listWidth: number,
788
791
  previewWidth: number,
789
- previewStats: string,
790
792
  ): string => {
791
- const help = showAllWorkspaces
792
- ? " Opt+W/Ctrl+T current workspace · Esc close "
793
- : " Opt+W/Ctrl+T all workspaces · Esc close ";
794
793
  const left = `└${"─".repeat(Math.max(0, listWidth - 1))}`;
795
- const right = `${"─".repeat(Math.max(0, previewWidth - help.length - 1))}${help}┘`;
796
- return `${theme.fg("border", left)}${theme.fg("border", "─┴─")}${theme.fg("border", right)}`;
794
+ const right = `${"─".repeat(Math.max(0, previewWidth - 1))}┘`;
795
+ return `${theme.fg("borderMuted", left)}${theme.fg("borderMuted", "─┴─")}${theme.fg("borderMuted", right)}`;
797
796
  };
798
797
 
799
798
  const renderSplitPane = (width: number): string[] => {
800
799
  const layout = getSessionPaneLayout(width);
801
800
  const termRows = Math.max(12, tui.terminal?.rows ?? 24);
802
- const contentHeight = Math.max(8, termRows - 2);
801
+ // Frame (2) + hint line (1).
802
+ const contentHeight = Math.max(8, termRows - 3);
803
803
  const filterLine = filter.length
804
804
  ? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}`
805
805
  : `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`;
@@ -861,26 +861,41 @@ async function showSessionPicker(
861
861
  previewScrollOffset,
862
862
  renderedPreview.maxScroll,
863
863
  );
864
- const modeHints = `t ${toolsExpanded ? "compact" : "tools"} h ${thinkingVisible ? "hide thinking" : "thinking"}`;
865
- const previewStats =
866
- renderedPreview.maxScroll > 0
867
- ? ` ${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines} • pgup/pgdn • ${modeHints} `
868
- : ` esc/enter • ${modeHints} `;
864
+ // Hints live on their own line under the frame instead of being packed
865
+ // into the bottom border, where they had to compete with the border for
866
+ // the same row and lost the keys in a wall of dim text.
867
+ const hints = hintLine(
868
+ theme,
869
+ [
870
+ renderedPreview.maxScroll > 0
871
+ ? ([
872
+ "",
873
+ `${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines}`,
874
+ ] as const)
875
+ : undefined,
876
+ renderedPreview.maxScroll > 0
877
+ ? (["pgup/pgdn", "scroll"] as const)
878
+ : undefined,
879
+ ["t", toolsExpanded ? "compact" : "tools"],
880
+ ["h", thinkingVisible ? "hide thinking" : "thinking"],
881
+ [
882
+ "opt+w/ctrl+t",
883
+ showAllWorkspaces ? "current workspace" : "all workspaces",
884
+ ],
885
+ ["esc", "close"],
886
+ ],
887
+ width,
888
+ );
869
889
 
870
890
  const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)];
871
891
  for (let i = 0; i < contentHeight; i++) {
872
892
  const left = padAnsiRight(leftLines[i] ?? "", layout.listWidth);
873
893
  const right =
874
894
  renderedPreview.lines[i] ?? " ".repeat(layout.previewWidth);
875
- lines.push(`${left}${theme.fg("border", " │ ")}${right}`);
895
+ lines.push(`${left}${theme.fg("borderMuted", " │ ")}${right}`);
876
896
  }
877
- lines.push(
878
- buildBottomBorder(
879
- layout.listWidth,
880
- layout.previewWidth,
881
- previewStats,
882
- ),
883
- );
897
+ lines.push(buildBottomBorder(layout.listWidth, layout.previewWidth));
898
+ lines.push(hints);
884
899
  return lines.map((line) => truncateToWidth(line, width, "", true));
885
900
  };
886
901
 
@@ -132,10 +132,10 @@ export function buildInteractiveSetupPrompt(options: {
132
132
  "- Capability discovery: explicit is the safe default and keeps OpenPI model tools absent until the user asks for a capability. adaptive is opt-in and keeps only the small openpi_load_tools gateway visible, allowing the model to load Subagents, Workflows, background terminals, structured search, or Session tracking when it judges them useful. Loaded groups remain session-stable, and normal permission, concurrency, and workflow limits still apply.",
133
133
  "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.",
134
134
  "- Workflow fan-out: concurrency controls simultaneous agents and resource pressure; max agent calls controls the total capacity of one workflow. Valid ranges are 1-64 and 1-1024.",
135
- "- UI: the large header costs vertical space; the custom footer is a declarative dashboard. Presets: powerline (one-line ANSI256 blocks), powerline-mono (one-line high-contrast gray powerline), and compact (one-line plain text); the default is plain with cwd/git/pr on the left and model/context/cost on the right. Style can also be set independently: plain, powerline, powerline-mono. Custom lines are a 2D layout of cwd/model/thinking/context/cache/cost/throughput/git/pr plus at most one flex per line for left/right alignment. Nerd Font only affects powerline separator glyphs; text stays readable without it. Changes apply immediately in the active TUI session.",
135
+ "- UI: the large header costs vertical space; the custom footer is a declarative dashboard. Presets: powerline (one-line ANSI256 blocks), powerline-mono (one-line high-contrast gray powerline), and compact (one-line plain text); the default is plain with model/context on the left and git/pr/cwd on the right. Style can also be set independently: plain, powerline, powerline-mono. Custom lines are a 2D layout of cwd/model/thinking/context/cache/cost/throughput/git/pr plus at most one flex per line for left/right alignment. Footer metrics use Codicon outline glyphs for model, context, and directory; a Nerd Font renders them as designed while the text stays readable without it. Changes apply immediately in the active TUI session.",
136
136
  "- Operational activity for Subagents, Workflows, and background terminals is core status and always remains visible whenever the custom footer is enabled.",
137
137
  "- Post-edit command: one optional shell command (maximum 500 characters) run in the background after a turn with successful Write/Edit operations (e.g. `npm run format`). Off by default, interactive TUI sessions only, failures surface as a notification. This is a single command, not an event-hook system.",
138
- "- Result detail display: Subagent results, Bash operations, and Write/Edit operations can each default to full or compact. Compact Subagent results show only bounded status rows and keep raw child reports behind app.tools.expand; compact Bash and Write/Edit operations use folded previews. Ctrl+O expands compact output by default. Bash and Write/Edit default to compact. Recommend compact for users who do not usually inspect implementation details.",
138
+ "- Result detail display: Subagent results, Bash operations, and Write/Edit operations can each default to full or compact. Compact Subagent results show only bounded status rows and keep raw child reports behind app.tools.expand; compact Bash and Write/Edit operations use one-line semantic activity summaries. Read, grep, find, and ls use the same compact activity-row projection. Ctrl+O restores Pi's native full arguments, output, errors, diffs, and timing. Bash and Write/Edit default to compact. Recommend compact for users who scan activity first and inspect evidence on demand.",
139
139
  "- Agent role models: built-in explorer, implementer, reviewer, and advisor roles are shared by subagent_spawn and workflow agent_type, and inherit the parent model by default. Assign only an available registry model to an individual role when needed; clearing that role returns it to inheritance. Custom agent-type files still override a built-in role's complete definition.",
140
140
  "- Intercom: optional cross-session messaging is installed only after a native setup confirmation. It stays parent-only; Direct/Workflow children and Replay cannot use it. The status above is informational for this model-guided step—do not install packages or edit its config yourself.",
141
141
  "",
@@ -371,7 +371,7 @@ export default function openPiSetup(pi: ExtensionAPI) {
371
371
  ui_footer_style: Type.Optional(
372
372
  StringEnum(FOOTER_STYLES, {
373
373
  description:
374
- "Footer visual style: plain (Pi theme separators), powerline (ANSI256 colored blocks with  seams), powerline-mono (high-contrast gray powerline). Nerd Font improves separator glyphs only. Omit to preserve the current style (or the preset's style when a preset is applied).",
374
+ "Footer visual style: plain (Pi theme separators), powerline (ANSI256 colored blocks with  seams), powerline-mono (high-contrast gray powerline). A Nerd Font renders Codicon metric glyphs and powerline seams as designed; text stays readable without it. Omit to preserve the current style (or the preset's style when a preset is applied).",
375
375
  }),
376
376
  ),
377
377
  ui_footer_lines: Type.Optional(
@@ -401,13 +401,13 @@ export default function openPiSetup(pi: ExtensionAPI) {
401
401
  bash_tool_display: Type.Optional(
402
402
  StringEnum(DETAIL_DISPLAYS, {
403
403
  description:
404
- "How Bash commands and output render by default: compact keeps a one-line command plus a bounded output preview with a hidden-line count and expands with app.tools.expand; full keeps every command expanded. Omit to preserve the current value.",
404
+ "How Bash commands and output render by default: compact shows one semantic activity row with running/success/failure state; app.tools.expand restores Pi's native command, output, error, timing, and full-output metadata. Full keeps Pi's native rendering expanded by default. Omit to preserve the current value.",
405
405
  }),
406
406
  ),
407
407
  file_mutation_display: Type.Optional(
408
408
  StringEnum(DETAIL_DISPLAYS, {
409
409
  description:
410
- "How Write/Edit content and diffs render by default: compact shows a Claude Code-style folded preview with a hidden-line count and expands with app.tools.expand; full keeps every operation expanded. Omit to preserve the current value.",
410
+ "How Write/Edit content and diffs render by default: compact shows one semantic activity row with path, status, and line/diff counts; app.tools.expand restores Pi's native preview, output, error, and diff. Full keeps Pi's native rendering expanded by default. Omit to preserve the current value.",
411
411
  }),
412
412
  ),
413
413
  subagent_role_models: Type.Optional(SUBAGENT_ROLE_MODELS_SCHEMA),
@@ -573,7 +573,7 @@ export default function openPiSetup(pi: ExtensionAPI) {
573
573
  "Current configuration:",
574
574
  currentConfiguration,
575
575
  "",
576
- "Capability discovery is explicit by default; adaptive is an opt-in that keeps only openpi_load_tools visible so the model may load useful groups. Footer tips: presets are powerline, powerline-mono, compact; style is plain/powerline/powerline-mono; custom layouts use ui_footer_lines (2D enum arrays with optional flex). Do not use ui_footer_items together with ui_footer_lines. Built-in Agent role models (explorer, implementer, reviewer, advisor) are shared by subagent_spawn and workflow agent_type; they inherit the parent unless assigned an available registry model, and clearing an assignment restores inheritance. Custom agent-type files still override built-in role definitions. Nerd Font only affects powerline separator glyphs. Changes apply immediately in the active TUI session. Intercom installation is handled only by the native setup confirmation; do not install packages or edit its config yourself.",
576
+ "Capability discovery is explicit by default; adaptive is an opt-in that keeps only openpi_load_tools visible so the model may load useful groups. Footer tips: presets are powerline, powerline-mono, compact; style is plain/powerline/powerline-mono; custom layouts use ui_footer_lines (2D enum arrays with optional flex). Do not use ui_footer_items together with ui_footer_lines. Built-in Agent role models (explorer, implementer, reviewer, advisor) are shared by subagent_spawn and workflow agent_type; they inherit the parent unless assigned an available registry model, and clearing an assignment restores inheritance. Custom agent-type files still override built-in role definitions. A Nerd Font renders Footer Codicons and powerline seams as designed; text stays readable without it. Changes apply immediately in the active TUI session. Intercom installation is handled only by the native setup confirmation; do not install packages or edit its config yourself.",
577
577
  "",
578
578
  "Use configure_my_pi_setup to apply only the requested OpenPI-owned changes and preserve everything else. Interpret model names from the available Pi registry. Do not edit configuration files directly.",
579
579
  ]
@@ -8,8 +8,6 @@ export interface ActivityCounts {
8
8
  failed: number;
9
9
  }
10
10
 
11
- const SQUARE = "■";
12
-
13
11
  /**
14
12
  * Settled work is an unread notice, not a session tally: `done`/`failed` stay
15
13
  * visible until the user's next explicit request acknowledges them, while
@@ -49,15 +47,18 @@ export function formatActivityStatus(
49
47
  label: "subagents" | "workflows",
50
48
  counts: ActivityCounts,
51
49
  ) {
50
+ // No status glyphs here: the footer line is a static string refreshed on
51
+ // events, so a spinner would freeze between updates — the colored words
52
+ // carry the state on their own.
52
53
  const parts: string[] = [];
53
54
  if (counts.running > 0) {
54
- parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`));
55
+ parts.push(theme.fg("warning", `${counts.running} running`));
55
56
  }
56
57
  if (counts.done > 0) {
57
- parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`));
58
+ parts.push(theme.fg("success", `${counts.done} done`));
58
59
  }
59
60
  if (counts.failed > 0) {
60
- parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`));
61
+ parts.push(theme.fg("error", `${counts.failed} failed`));
61
62
  }
62
63
  parts.push(theme.fg("accent", `/${label}`) + theme.fg("dim", " to view"));
63
64