tau-coding-agent 0.1.5 → 0.2.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 (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -0,0 +1,186 @@
1
+ import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import {
5
+ cloneRuntimeConfig,
6
+ escapeSlashCommandArg,
7
+ mutateStringList,
8
+ type ListOp,
9
+ type PromptMode,
10
+ } from "../config.js";
11
+ import type { SandboxEvent, SandboxEventOutcome, SandboxEventReason } from "../runtime.js";
12
+ import {
13
+ createPermissionResolution,
14
+ showPermissionDialog,
15
+ type PermissionResolution,
16
+ } from "./dialog.js";
17
+
18
+ interface MachLookupViolation {
19
+ service: string;
20
+ }
21
+
22
+ function detectMachLookupViolationFromLine(line: string): MachLookupViolation | null {
23
+ const match = line.match(/\bdeny\(\d+\)\s+mach-lookup\s+([^\s()"'*]+)/i);
24
+ const service = match?.[1];
25
+ return service ? { service } : null;
26
+ }
27
+
28
+ export function detectMachLookupViolations(lines: string[]): MachLookupViolation[] {
29
+ const violationsByService = new Map<string, MachLookupViolation>();
30
+
31
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
32
+ const violation = detectMachLookupViolationFromLine(lines[index]);
33
+ if (violation && !violationsByService.has(violation.service)) {
34
+ violationsByService.set(violation.service, violation);
35
+ }
36
+ }
37
+
38
+ return Array.from(violationsByService.values());
39
+ }
40
+
41
+ export function isValidMachLookupRule(rule: string): boolean {
42
+ const trimmed = rule.trim();
43
+ if (!trimmed || /\s/.test(trimmed)) return false;
44
+
45
+ const prefix = trimmed.endsWith("*") ? trimmed.slice(0, -1) : trimmed;
46
+ return !prefix.includes("*");
47
+ }
48
+
49
+ function matchesMachLookupRule(service: string, rule: string): boolean {
50
+ if (rule === "*") return true;
51
+ if (rule.endsWith("*")) return service.startsWith(rule.slice(0, -1));
52
+ return service === rule;
53
+ }
54
+
55
+ export function mutateMachLookupAllowList(
56
+ runtimeConfig: SandboxRuntimeConfig,
57
+ op: ListOp,
58
+ service: string,
59
+ ): boolean {
60
+ runtimeConfig.network.allowMachLookup ??= [];
61
+ return mutateStringList(runtimeConfig.network.allowMachLookup, op, service);
62
+ }
63
+
64
+ function isMachLookupAlreadyAllowed(
65
+ runtimeConfig: SandboxRuntimeConfig | null,
66
+ service: string,
67
+ ): boolean {
68
+ return (runtimeConfig?.network.allowMachLookup ?? []).some((rule) =>
69
+ matchesMachLookupRule(service, rule),
70
+ );
71
+ }
72
+
73
+ function buildMachLookupAllowCommand(service: string): string {
74
+ return `/sandbox mach-lookup add ${escapeSlashCommandArg(service)}`;
75
+ }
76
+
77
+ function formatMachLookupViolationSummary(service: string): string {
78
+ return `[sandbox] Blocked macOS service lookup: ${service}`;
79
+ }
80
+
81
+ function describeMachLookupEventSummary(
82
+ reason: SandboxEventReason,
83
+ outcome: SandboxEventOutcome,
84
+ ): string {
85
+ if (outcome === "allowed") return "user allowed macOS service lookup for this session";
86
+ if (reason === "already-approved-still-failed") {
87
+ return "macOS service lookup was previously allowed for this session but is still failing";
88
+ }
89
+ return "macOS service lookup is not in the allowed service list";
90
+ }
91
+
92
+ function formatMachLookupBlockedTarget(service: string): string {
93
+ return `Sandbox blocked access to macOS service ${service}.`;
94
+ }
95
+
96
+ export async function handleMachLookupViolation(options: {
97
+ ctx: ExtensionContext | null;
98
+ promptMode: PromptMode;
99
+ runtimeConfig: SandboxRuntimeConfig;
100
+ violations: MachLookupViolation[];
101
+ command: string;
102
+ cwd?: string;
103
+ pendingDialogs?: Map<string, Promise<PermissionResolution | null>>;
104
+ applyRuntimeConfigForSession?: (
105
+ ctx: ExtensionContext,
106
+ runtimeConfig: SandboxRuntimeConfig,
107
+ ) => void;
108
+ recordEvent?: (event: SandboxEvent) => void;
109
+ autoRetryAvailable?: boolean;
110
+ }): Promise<PermissionResolution | null> {
111
+ const {
112
+ ctx,
113
+ promptMode,
114
+ runtimeConfig,
115
+ violations,
116
+ command,
117
+ cwd,
118
+ pendingDialogs,
119
+ applyRuntimeConfigForSession,
120
+ recordEvent,
121
+ autoRetryAvailable = true,
122
+ } = options;
123
+ if (violations.length === 0) return null;
124
+
125
+ const violation =
126
+ violations.find((candidate) => !isMachLookupAlreadyAllowed(runtimeConfig, candidate.service)) ??
127
+ violations[0];
128
+ const { service } = violation;
129
+ const blockedTarget = formatMachLookupBlockedTarget(service);
130
+ const allowCommand = buildMachLookupAllowCommand(service);
131
+ const alreadyApproved = isMachLookupAlreadyAllowed(runtimeConfig, service);
132
+ const eventReason: SandboxEventReason = alreadyApproved
133
+ ? "already-approved-still-failed"
134
+ : "missing-mach-lookup";
135
+
136
+ const recordMachLookupEvent = (outcome: SandboxEventOutcome): void => {
137
+ recordEvent?.({
138
+ timestamp: Date.now(),
139
+ kind: "mach-lookup",
140
+ outcome,
141
+ reason: eventReason,
142
+ target: service,
143
+ command,
144
+ cwd,
145
+ summary: describeMachLookupEventSummary(eventReason, outcome),
146
+ suggestedCommand: outcome === "blocked" && !alreadyApproved ? allowCommand : undefined,
147
+ });
148
+ };
149
+
150
+ if (promptMode === "non-interactive" || !ctx?.hasUI) {
151
+ recordMachLookupEvent("blocked");
152
+ return {
153
+ kind: "deny",
154
+ message: `${formatMachLookupViolationSummary(service)}\n[sandbox] To temporarily allow for this session, run: ${allowCommand}`,
155
+ };
156
+ }
157
+
158
+ if (alreadyApproved) {
159
+ recordMachLookupEvent("blocked");
160
+ return createPermissionResolution("allow-adapt", blockedTarget, false);
161
+ }
162
+
163
+ const promptKey = `${allowCommand}:${autoRetryAvailable ? "retry" : "adapt"}`;
164
+ return showPermissionDialog({
165
+ ctx,
166
+ title: `Sandbox blocked access to macOS service ${service}`,
167
+ promptKey,
168
+ pendingDialogs,
169
+ autoRetryAvailable,
170
+ onDecision(decision) {
171
+ if (decision === "deny") {
172
+ recordMachLookupEvent("blocked");
173
+ return createPermissionResolution(decision, blockedTarget);
174
+ }
175
+
176
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
177
+ const changed = mutateMachLookupAllowList(nextConfig, "add", service);
178
+ if (changed) {
179
+ applyRuntimeConfigForSession?.(ctx, nextConfig);
180
+ }
181
+
182
+ recordMachLookupEvent("allowed");
183
+ return createPermissionResolution(decision, blockedTarget, changed);
184
+ },
185
+ });
186
+ }
@@ -0,0 +1,164 @@
1
+ import type { SandboxAskCallback, SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import {
5
+ cloneRuntimeConfig,
6
+ escapeSlashCommandArg,
7
+ mutateStringList,
8
+ type PromptMode,
9
+ } from "../config.js";
10
+ type NetworkEventOutcome = "blocked" | "allowed";
11
+ type NetworkEventReason = "explicit-deny-domain" | "missing-allowed-domain";
12
+
13
+ interface NetworkPermissionEvent {
14
+ outcome: NetworkEventOutcome;
15
+ reason: NetworkEventReason;
16
+ target: string;
17
+ summary: string;
18
+ suggestedCommand?: string;
19
+ }
20
+
21
+ interface NetworkPermissions {
22
+ ask: SandboxAskCallback;
23
+ clear(): void;
24
+ }
25
+
26
+ export function createNetworkPermissions(options: {
27
+ getContext: () => ExtensionContext | null;
28
+ getPromptMode: () => PromptMode;
29
+ getRuntimeConfig: () => SandboxRuntimeConfig | null;
30
+ isSuspended: () => boolean;
31
+ applyRuntimeConfigForSession: (
32
+ ctx: ExtensionContext,
33
+ runtimeConfig: SandboxRuntimeConfig,
34
+ ) => void;
35
+ recordEvent: (event: NetworkPermissionEvent) => void;
36
+ notify: (ctx: ExtensionContext, text: string, level?: "info" | "warning" | "error") => void;
37
+ }): NetworkPermissions {
38
+ const {
39
+ getContext,
40
+ getPromptMode,
41
+ getRuntimeConfig,
42
+ isSuspended,
43
+ applyRuntimeConfigForSession,
44
+ recordEvent,
45
+ notify,
46
+ } = options;
47
+ const pendingApprovals = new Map<string, Promise<boolean>>();
48
+
49
+ function recordNetworkEvent(
50
+ outcome: NetworkEventOutcome,
51
+ reason: NetworkEventReason,
52
+ host: string,
53
+ port?: number,
54
+ ): void {
55
+ recordEvent({
56
+ outcome,
57
+ reason,
58
+ target: port ? `${host}:${port}` : host,
59
+ summary: describeNetworkEventSummary(reason, outcome),
60
+ suggestedCommand: outcome === "blocked" ? buildNetworkBlockCommand(reason, host) : undefined,
61
+ });
62
+ }
63
+
64
+ const ask: SandboxAskCallback = async ({ host, port }) => {
65
+ if (isSuspended()) return true;
66
+
67
+ const normalizedHost = host.toLowerCase();
68
+ const existingDecision = pendingApprovals.get(normalizedHost);
69
+ if (existingDecision) return existingDecision;
70
+
71
+ const decision = (async () => {
72
+ try {
73
+ const initialConfig = getRuntimeConfig();
74
+ if (!initialConfig) return false;
75
+
76
+ if (initialConfig.network.allowedDomains.includes(normalizedHost)) return true;
77
+ if (initialConfig.network.deniedDomains.includes(normalizedHost)) {
78
+ recordNetworkEvent("blocked", "explicit-deny-domain", normalizedHost, port);
79
+ return false;
80
+ }
81
+
82
+ const suggestedCommand = buildNetworkBlockCommand("missing-allowed-domain", normalizedHost);
83
+ const ctx = getContext();
84
+ if (getPromptMode() === "non-interactive" || !ctx || !ctx.hasUI) {
85
+ recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
86
+ const message = `Sandbox blocked network access to ${normalizedHost}. To temporarily allow for this session, run: ${suggestedCommand}`;
87
+ if (ctx) notify(ctx, message, "warning");
88
+ else console.warn(message);
89
+ return false;
90
+ }
91
+
92
+ const target = port ? `${normalizedHost}:${port}` : normalizedHost;
93
+ const approved = await ctx.ui.confirm(
94
+ `Sandbox blocked network access to ${target}`,
95
+ "\nAllow for this session?",
96
+ );
97
+ if (!approved) {
98
+ recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
99
+ return false;
100
+ }
101
+
102
+ const latestConfig = getRuntimeConfig();
103
+ if (!latestConfig) return false;
104
+ if (latestConfig.network.deniedDomains.includes(normalizedHost)) {
105
+ recordNetworkEvent("blocked", "explicit-deny-domain", normalizedHost, port);
106
+ notify(
107
+ ctx,
108
+ `Network access to ${normalizedHost} remains denied by current sandbox policy. Remove it from deny list to allow.`,
109
+ "warning",
110
+ );
111
+ return false;
112
+ }
113
+ if (latestConfig.network.allowedDomains.includes(normalizedHost)) {
114
+ recordNetworkEvent("allowed", "missing-allowed-domain", normalizedHost, port);
115
+ return true;
116
+ }
117
+
118
+ const nextConfig = cloneRuntimeConfig(latestConfig);
119
+ const changed = mutateStringList(nextConfig.network.allowedDomains, "add", normalizedHost);
120
+ if (changed) {
121
+ applyRuntimeConfigForSession(ctx, nextConfig);
122
+ }
123
+
124
+ recordNetworkEvent("allowed", "missing-allowed-domain", normalizedHost, port);
125
+ notify(ctx, `Allowed network domain for this session: ${normalizedHost}`, "info");
126
+ return true;
127
+ } catch (error) {
128
+ const ctx = getContext();
129
+ const message = `Sandbox permission prompt failed for ${normalizedHost}: ${error instanceof Error ? error.message : error}`;
130
+ if (ctx) notify(ctx, message, "warning");
131
+ else console.warn(message);
132
+ return false;
133
+ }
134
+ })();
135
+
136
+ pendingApprovals.set(normalizedHost, decision);
137
+ try {
138
+ return await decision;
139
+ } finally {
140
+ pendingApprovals.delete(normalizedHost);
141
+ }
142
+ };
143
+
144
+ return {
145
+ ask,
146
+ clear: () => pendingApprovals.clear(),
147
+ };
148
+ }
149
+
150
+ function buildNetworkBlockCommand(reason: NetworkEventReason, host: string): string {
151
+ if (reason === "explicit-deny-domain") {
152
+ return `/sandbox network deny remove ${escapeSlashCommandArg(host)}`;
153
+ }
154
+ return `/sandbox network allow add ${escapeSlashCommandArg(host)}`;
155
+ }
156
+
157
+ function describeNetworkEventSummary(
158
+ reason: NetworkEventReason,
159
+ outcome: NetworkEventOutcome,
160
+ ): string {
161
+ if (outcome === "allowed") return "user allowed network domain for this session";
162
+ if (reason === "explicit-deny-domain") return "network access matched a deny list entry";
163
+ return "network access target is not in the allowed domain list";
164
+ }
@@ -0,0 +1,270 @@
1
+ import { homedir } from "node:os";
2
+ import {
3
+ DynamicBorder,
4
+ type ExtensionContext,
5
+ type KeybindingsManager,
6
+ type Theme,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ CURSOR_MARKER,
10
+ Text,
11
+ TUI_KEYBINDINGS,
12
+ visibleWidth,
13
+ type Component,
14
+ type Focusable,
15
+ type TUI,
16
+ type TuiMouseEvent,
17
+ type TuiMouseEventResult,
18
+ } from "@earendil-works/pi-tui";
19
+
20
+ export const UNSANDBOXED_APPROVAL_CHOICES = ["Deny", "Run once outside sandbox"] as const;
21
+
22
+ export function isUnsandboxedApproval(choices: readonly string[]): boolean {
23
+ return (
24
+ choices.length === UNSANDBOXED_APPROVAL_CHOICES.length &&
25
+ choices.every((choice, index) => choice === UNSANDBOXED_APPROVAL_CHOICES[index])
26
+ );
27
+ }
28
+
29
+ /** Use Pi's editor-area dialog. RPC presentation belongs to its human client. */
30
+ export async function showUnsandboxedApproval(
31
+ ctx: ExtensionContext,
32
+ title: string,
33
+ signal?: AbortSignal,
34
+ ): Promise<string | undefined> {
35
+ if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return undefined;
36
+ let cancel: (() => void) | undefined;
37
+ try {
38
+ const result = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
39
+ cancel = () => done(undefined);
40
+ signal?.addEventListener("abort", cancel, { once: true });
41
+ if (signal?.aborted) cancel();
42
+ return new UnsandboxedApproval(title, tui, theme, keybindings, (choice) =>
43
+ done(signal?.aborted ? undefined : choice),
44
+ );
45
+ });
46
+ return signal?.aborted ? undefined : result;
47
+ } catch {
48
+ return undefined;
49
+ } finally {
50
+ if (cancel) signal?.removeEventListener("abort", cancel);
51
+ }
52
+ }
53
+
54
+ /** Shell-style details for both the local dialog and forwarded RPC approvals. */
55
+ export function formatUnsandboxedApproval(command: string, cwd: string): string {
56
+ const home = homedir();
57
+ const homePrefix = home.endsWith("/") ? home : `${home}/`;
58
+ const directory =
59
+ cwd === home ? "~" : cwd.startsWith(homePrefix) ? `~/${cwd.slice(homePrefix.length)}` : cwd;
60
+ return [
61
+ "Run once outside sandbox?",
62
+ "",
63
+ escapeControls(directory).replaceAll("\n", "\\u000a"),
64
+ `$ ${escapeControls(command)}`,
65
+ "",
66
+ "This command and its descendants get host filesystem and network access.",
67
+ ].join("\n");
68
+ }
69
+
70
+ /** The cursor keeps the focused review line or choice visible when Pi shrinks its editor dock. */
71
+ class UnsandboxedApproval implements Component, Focusable {
72
+ focused = false;
73
+ private readonly content = new Text("", 0, 0);
74
+ private readonly titleLines: string[];
75
+ private readonly directoryLine: number;
76
+ private selected = 0;
77
+ private reviewLine: number | undefined;
78
+ private scrollTop = 0;
79
+ private contentLines: string[] = [];
80
+ private viewportHeight = 0;
81
+ private renderedWidth = 0;
82
+ private renderedHeight = 0;
83
+ private canApprove = false;
84
+
85
+ constructor(
86
+ title: string,
87
+ private readonly tui: TUI,
88
+ private readonly theme: Theme,
89
+ private readonly keybindings: KeybindingsManager,
90
+ private readonly done: (choice: string | undefined) => void,
91
+ ) {
92
+ this.titleLines = escapeControls(title).split("\n");
93
+ this.directoryLine = this.titleLines.findIndex((line) => line.startsWith("$ ")) - 1;
94
+ }
95
+
96
+ render(width: number): string[] {
97
+ const rows = this.tui.terminal.rows;
98
+ const height = Math.min(16, Math.max(0, rows - 2));
99
+ if (width !== this.renderedWidth || rows !== this.renderedHeight) this.selected = 0;
100
+ this.renderedWidth = width;
101
+ this.renderedHeight = rows;
102
+ this.canApprove = false;
103
+ // Fullscreen owns unmodified PageUp/PageDown and Home/End before dialog input.
104
+ // Advertise the configured editor aliases that can actually reach this dialog.
105
+ const viewportKeys = new Set(
106
+ this.tui.mode === "fullscreen"
107
+ ? Object.keys(TUI_KEYBINDINGS)
108
+ .filter((action) => action.startsWith("tui.altScreen."))
109
+ .flatMap((action) => this.keybindings.getKeys(action as keyof typeof TUI_KEYBINDINGS))
110
+ : [],
111
+ );
112
+ const key = (action: Parameters<KeybindingsManager["getKeys"]>[0]) =>
113
+ this.keybindings
114
+ .getKeys(action)
115
+ .find((binding) => !action.startsWith("tui.editor.") || !viewportKeys.has(binding)) ??
116
+ "unbound";
117
+ const border = new DynamicBorder((line) => this.theme.fg("border", line)).render(width);
118
+ const gap = height >= 14 ? [""] : [];
119
+ this.content.setText(
120
+ this.titleLines
121
+ .map((line, index) =>
122
+ index === this.directoryLine
123
+ ? this.theme.fg("dim", line)
124
+ : line.startsWith("$ ")
125
+ ? this.theme.fg("text", line)
126
+ : line,
127
+ )
128
+ .join("\n"),
129
+ );
130
+ this.contentLines = this.content.render(Math.max(1, width - 2));
131
+ const choices = UNSANDBOXED_APPROVAL_CHOICES.flatMap((choice, index) =>
132
+ new Text(
133
+ this.theme.fg(
134
+ index === this.selected ? "accent" : "text",
135
+ `${this.focused && this.reviewLine === undefined && index === this.selected ? CURSOR_MARKER : ""}${index === this.selected ? "→" : " "} ${choice}`,
136
+ ),
137
+ 1,
138
+ 0,
139
+ ).render(width),
140
+ );
141
+ const navigation =
142
+ key("tui.select.up") === "up" && key("tui.select.down") === "down"
143
+ ? "↑↓"
144
+ : `${key("tui.select.up")}/${key("tui.select.down")}`;
145
+ const hint = (binding: string, description: string) =>
146
+ this.theme.fg("dim", binding) + this.theme.fg("muted", ` ${description}`);
147
+ const hints = new Text(
148
+ `${hint(navigation, "navigate")} ${hint(key("tui.select.confirm"), "select")} ${hint(key("tui.select.cancel"), "cancel")}`,
149
+ 1,
150
+ 0,
151
+ ).render(width);
152
+ this.viewportHeight =
153
+ height - border.length * 2 - gap.length * 4 - choices.length - hints.length;
154
+ const reviewHints =
155
+ this.contentLines.length > this.viewportHeight || this.reviewLine !== undefined
156
+ ? new Text(
157
+ this.theme.fg(
158
+ "dim",
159
+ `${key("tui.editor.pageUp")}/${key("tui.editor.pageDown")} review lines · ${key("tui.editor.cursorLineStart")}/${key("tui.editor.cursorLineEnd")} top/end`,
160
+ ),
161
+ 1,
162
+ 0,
163
+ ).render(width)
164
+ : [];
165
+ this.viewportHeight -= reviewHints.length;
166
+ if (width < 3 || this.viewportHeight < 1) {
167
+ return new Text("Resize to review. Escape denies.", 0, 0)
168
+ .render(Math.max(1, width))
169
+ .slice(0, Math.max(0, height));
170
+ }
171
+ this.viewportHeight = Math.min(this.viewportHeight, this.contentLines.length);
172
+ if (this.reviewLine !== undefined) {
173
+ this.reviewLine = Math.min(this.reviewLine, this.contentLines.length - 1);
174
+ this.scrollTop = Math.max(0, this.reviewLine - this.viewportHeight + 1);
175
+ }
176
+ this.scrollTop = Math.min(
177
+ this.scrollTop,
178
+ Math.max(0, this.contentLines.length - this.viewportHeight),
179
+ );
180
+ this.canApprove =
181
+ this.focused &&
182
+ this.reviewLine === undefined &&
183
+ this.selected === 1 &&
184
+ width >= visibleWidth(` → ${UNSANDBOXED_APPROVAL_CHOICES[1]} `);
185
+ const visible = Array.from({ length: this.viewportHeight }, (_, index) => {
186
+ const row = this.scrollTop + index;
187
+ const line = this.contentLines[row] ?? "";
188
+ const cursor = this.focused && row === this.reviewLine ? CURSOR_MARKER : "";
189
+ const text = row === 0 ? this.theme.fg("accent", this.theme.bold(line)) : line;
190
+ return ` ${cursor}${text}`;
191
+ });
192
+ return [
193
+ ...border,
194
+ ...gap,
195
+ ...visible,
196
+ ...gap,
197
+ ...choices,
198
+ ...gap,
199
+ ...hints,
200
+ ...reviewHints,
201
+ ...gap,
202
+ ...border,
203
+ ];
204
+ }
205
+
206
+ handleInput(data: string): void {
207
+ const kb = this.keybindings;
208
+ if (kb.matches(data, "tui.select.cancel")) {
209
+ this.done(undefined);
210
+ return;
211
+ }
212
+ if (
213
+ this.renderedWidth !== this.tui.terminal.columns ||
214
+ this.renderedHeight !== this.tui.terminal.rows
215
+ ) {
216
+ this.canApprove = false;
217
+ this.selected = 0;
218
+ this.tui.requestRender();
219
+ return;
220
+ }
221
+ if (kb.matches(data, "tui.select.confirm")) {
222
+ if (this.selected === 0) this.done("Deny");
223
+ else if (this.canApprove) this.done("Run once outside sandbox");
224
+ return;
225
+ }
226
+ if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
227
+ this.selected = kb.matches(data, "tui.select.down") && this.viewportHeight > 0 ? 1 : 0;
228
+ this.reviewLine = undefined;
229
+ this.canApprove = false;
230
+ }
231
+ // Pi does not expose the editor dock's height. Advance one review line so its
232
+ // cursor can reveal every line even when only part of this component fits.
233
+ else if (kb.matches(data, "tui.editor.pageUp")) this.scroll(-1);
234
+ else if (kb.matches(data, "tui.editor.pageDown")) this.scroll(1);
235
+ else if (kb.matches(data, "tui.editor.cursorLineStart")) this.scroll(-this.contentLines.length);
236
+ else if (kb.matches(data, "tui.editor.cursorLineEnd")) this.scroll(this.contentLines.length);
237
+ this.tui.requestRender();
238
+ }
239
+
240
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
241
+ if (event.type !== "wheel") return undefined;
242
+ this.scroll(event.wheelDelta ?? 0);
243
+ return { handled: true, render: true };
244
+ }
245
+
246
+ invalidate(): void {
247
+ this.content.invalidate();
248
+ }
249
+
250
+ private scroll(lines: number): void {
251
+ this.selected = 0;
252
+ this.canApprove = false;
253
+ this.reviewLine = Math.max(
254
+ 0,
255
+ Math.min(this.contentLines.length - 1, (this.reviewLine ?? 0) + lines),
256
+ );
257
+ }
258
+ }
259
+
260
+ /** Preserve layout newlines while making untrusted terminal and Unicode controls inert. */
261
+ function escapeControls(text: string): string {
262
+ return text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (character) =>
263
+ character === "\n"
264
+ ? character
265
+ : character
266
+ .split("")
267
+ .map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`)
268
+ .join(""),
269
+ );
270
+ }