@tt-a1i/openpi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * cron: user-scheduled prompts for this session.
3
+ *
4
+ * `/cron every 5m <prompt>` or `/cron in 30s <prompt>` queues a prompt that
5
+ * fires on a timer. Jobs live only in this session (in memory, cleared on
6
+ * shutdown) — deliberately no persisted config and no model tool: this is a
7
+ * user affordance, not something the agent schedules for itself.
8
+ *
9
+ * A job fires only when the session is idle, delivered the same way every
10
+ * other background completion is (followUp + triggerTurn), so it can never
11
+ * interrupt a streaming turn.
12
+ */
13
+
14
+ import type {
15
+ ExtensionAPI,
16
+ ExtensionContext,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ advanceDeliveredJobs,
20
+ dueJobs,
21
+ formatInterval,
22
+ parseCronCommand,
23
+ type CronJob,
24
+ } from "./schedule.ts";
25
+
26
+ /** How often the scheduler looks for due jobs. */
27
+ const POLL_MS = 30_000;
28
+
29
+ interface CronRuntime {
30
+ now(): number;
31
+ startPolling(tick: () => void): () => void;
32
+ }
33
+
34
+ const SYSTEM_RUNTIME: CronRuntime = {
35
+ now: Date.now,
36
+ startPolling(tick) {
37
+ const timer = setInterval(tick, POLL_MS);
38
+ // Never hold the process open for a scheduled prompt.
39
+ timer.unref?.();
40
+ return () => clearInterval(timer);
41
+ },
42
+ };
43
+
44
+ export default function cron(
45
+ pi: ExtensionAPI,
46
+ runtime: CronRuntime = SYSTEM_RUNTIME,
47
+ ) {
48
+ let jobs: CronJob[] = [];
49
+ let nextId = 1;
50
+ let stopPolling: (() => void) | undefined;
51
+ let context: ExtensionContext | undefined;
52
+
53
+ const stopTicker = () => {
54
+ stopPolling?.();
55
+ stopPolling = undefined;
56
+ };
57
+
58
+ const fire = (job: CronJob) => {
59
+ try {
60
+ pi.sendMessage(
61
+ {
62
+ customType: "cron-fire",
63
+ content: job.prompt,
64
+ display: true,
65
+ details: { id: job.id, recurring: job.intervalMs !== undefined },
66
+ },
67
+ { deliverAs: "followUp", triggerTurn: true },
68
+ );
69
+ return true;
70
+ } catch {
71
+ // Session may be shutting down; leave the job due for the next tick.
72
+ return false;
73
+ }
74
+ };
75
+
76
+ const tick = () => {
77
+ const ctx = context;
78
+ if (!ctx || jobs.length === 0) return;
79
+ // Only fire into an idle session: a followUp during a live turn would
80
+ // queue behind it anyway, and firing on a timer mid-stream is surprising.
81
+ if (!ctx.isIdle()) return;
82
+ const now = runtime.now();
83
+ const due = dueJobs(jobs, now);
84
+ if (due.length === 0) return;
85
+ const deliveredIds = new Set<number>();
86
+ for (const job of due) {
87
+ if (fire(job)) deliveredIds.add(job.id);
88
+ }
89
+ jobs = advanceDeliveredJobs(jobs, deliveredIds, now);
90
+ if (jobs.length === 0) stopTicker();
91
+ };
92
+
93
+ const startTicker = () => {
94
+ if (stopPolling) return;
95
+ stopPolling = runtime.startPolling(tick);
96
+ };
97
+
98
+ pi.registerCommand("cron", {
99
+ description:
100
+ "Schedule a prompt for this session (`/cron every 5m <prompt>`, `/cron in 30s <prompt>`, `/cron list`, `/cron remove <id>`)",
101
+ handler: async (rawArgs, ctx) => {
102
+ const parsed = parseCronCommand(rawArgs);
103
+
104
+ if (parsed.action === "help") {
105
+ ctx.ui.notify(
106
+ parsed.error ??
107
+ "Usage: /cron every <5m> <prompt> · /cron in <30s> <prompt> · /cron list · /cron remove <id>\nJobs live in this session only and fire when the session is idle.",
108
+ parsed.error ? "warning" : "info",
109
+ );
110
+ return;
111
+ }
112
+
113
+ if (parsed.action === "list") {
114
+ if (jobs.length === 0) {
115
+ ctx.ui.notify("No scheduled prompts in this session.", "info");
116
+ return;
117
+ }
118
+ const now = runtime.now();
119
+ const lines = jobs.map((job) => {
120
+ const inSeconds = Math.max(
121
+ 0,
122
+ Math.round((job.nextRunAt - now) / 1000),
123
+ );
124
+ const when = job.intervalMs
125
+ ? `every ${formatInterval(job.intervalMs)}`
126
+ : "once";
127
+ return `${job.id}. ${when} · next in ${inSeconds}s · ${job.prompt}`;
128
+ });
129
+ ctx.ui.notify(lines.join("\n"), "info");
130
+ return;
131
+ }
132
+
133
+ if (parsed.action === "remove") {
134
+ const before = jobs.length;
135
+ jobs = jobs.filter((job) => job.id !== parsed.id);
136
+ if (jobs.length === 0) stopTicker();
137
+ ctx.ui.notify(
138
+ jobs.length === before
139
+ ? `No scheduled prompt with id ${parsed.id}.`
140
+ : `Removed scheduled prompt ${parsed.id}.`,
141
+ jobs.length === before ? "warning" : "info",
142
+ );
143
+ return;
144
+ }
145
+
146
+ const intervalMs = parsed.intervalMs!;
147
+ const job: CronJob = {
148
+ id: nextId++,
149
+ prompt: parsed.prompt!,
150
+ ...(parsed.oneShot ? {} : { intervalMs }),
151
+ nextRunAt: runtime.now() + intervalMs,
152
+ };
153
+ jobs.push(job);
154
+ startTicker();
155
+ ctx.ui.notify(
156
+ parsed.oneShot
157
+ ? `Scheduled prompt ${job.id} once in ${formatInterval(intervalMs)}.`
158
+ : `Scheduled prompt ${job.id} every ${formatInterval(intervalMs)}.`,
159
+ "info",
160
+ );
161
+ },
162
+ });
163
+
164
+ pi.on("session_start", (_event, ctx) => {
165
+ context = ctx;
166
+ });
167
+
168
+ pi.on("session_shutdown", () => {
169
+ stopTicker();
170
+ jobs = [];
171
+ context = undefined;
172
+ });
173
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Pure scheduling logic for `/cron`. Kept free of timers, IO, and session
3
+ * state so the tricky parts — parsing, due-ness, and how a recurring job
4
+ * advances after a missed window — are directly testable.
5
+ */
6
+
7
+ export interface CronJob {
8
+ readonly id: number;
9
+ /** Prompt delivered to the agent when the job fires. */
10
+ readonly prompt: string;
11
+ /** Milliseconds between runs; undefined for a one-shot job. */
12
+ readonly intervalMs?: number;
13
+ /** Absolute epoch ms of the next run. */
14
+ readonly nextRunAt: number;
15
+ }
16
+
17
+ const UNITS: Record<string, number> = {
18
+ s: 1_000,
19
+ m: 60_000,
20
+ h: 3_600_000,
21
+ };
22
+
23
+ export const MIN_INTERVAL_MS = 30_000;
24
+ export const CRON_PROMPT_MAX_CHARS = 2_000;
25
+
26
+ /**
27
+ * Parse a duration like `30s`, `5m`, `2h`. Deliberately a small duration
28
+ * grammar rather than crontab syntax: the ~30s poll cannot honor finer cron
29
+ * fields, so accepting them would promise precision that does not exist.
30
+ */
31
+ export function parseDuration(text: string): number | undefined {
32
+ const match = /^(\d+)\s*([smh])$/i.exec(text.trim());
33
+ if (!match) return undefined;
34
+ const value = Number(match[1]);
35
+ if (!Number.isFinite(value) || value <= 0) return undefined;
36
+ return value * UNITS[match[2].toLowerCase()];
37
+ }
38
+
39
+ export interface ParsedCronCommand {
40
+ action: "add" | "list" | "remove" | "help";
41
+ /** add: every <duration> (recurring) or in <duration> (one-shot). */
42
+ intervalMs?: number;
43
+ oneShot?: boolean;
44
+ prompt?: string;
45
+ id?: number;
46
+ error?: string;
47
+ }
48
+
49
+ /**
50
+ * Parse `/cron` arguments:
51
+ * every 5m <prompt> recurring
52
+ * in 30s <prompt> one-shot
53
+ * list | remove <id> management
54
+ */
55
+ export function parseCronCommand(raw: string): ParsedCronCommand {
56
+ const args = raw.trim();
57
+ if (!args || args === "help") return { action: "help" };
58
+ if (args === "list") return { action: "list" };
59
+
60
+ const remove = /^remove\s+(\d+)$/i.exec(args);
61
+ if (remove) return { action: "remove", id: Number(remove[1]) };
62
+
63
+ const schedule = /^(every|in)\s+(\S+)\s+([\s\S]+)$/i.exec(args);
64
+ if (!schedule) {
65
+ return {
66
+ action: "help",
67
+ error:
68
+ "Usage: /cron every <5m> <prompt> | in <30s> <prompt> | list | remove <id>",
69
+ };
70
+ }
71
+ const intervalMs = parseDuration(schedule[2]);
72
+ if (intervalMs === undefined) {
73
+ return {
74
+ action: "help",
75
+ error: `Could not read "${schedule[2]}" as a duration. Use 30s, 5m, or 2h.`,
76
+ };
77
+ }
78
+ if (intervalMs < MIN_INTERVAL_MS) {
79
+ return {
80
+ action: "help",
81
+ error: `Minimum interval is ${MIN_INTERVAL_MS / 1000}s (the scheduler polls about that often).`,
82
+ };
83
+ }
84
+ const prompt = schedule[3].trim().slice(0, CRON_PROMPT_MAX_CHARS);
85
+ if (!prompt) return { action: "help", error: "Provide a prompt to run." };
86
+ return {
87
+ action: "add",
88
+ intervalMs,
89
+ oneShot: schedule[1].toLowerCase() === "in",
90
+ prompt,
91
+ };
92
+ }
93
+
94
+ /** Jobs whose next run is at or before `now`. */
95
+ export function dueJobs(jobs: readonly CronJob[], now: number) {
96
+ return jobs.filter((job) => job.nextRunAt <= now);
97
+ }
98
+
99
+ /** Advance only jobs whose prompt was successfully queued for delivery. */
100
+ export function advanceDeliveredJobs(
101
+ jobs: readonly CronJob[],
102
+ deliveredIds: ReadonlySet<number>,
103
+ now: number,
104
+ ) {
105
+ return jobs.flatMap((job) => {
106
+ if (!deliveredIds.has(job.id)) return [job];
107
+ const next = advanceJob(job, now);
108
+ return next ? [next] : [];
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Next state for a job that just fired: one-shots drop out, recurring jobs
114
+ * schedule from NOW rather than from the missed slot, so a long busy gap can
115
+ * never produce a burst of catch-up runs.
116
+ */
117
+ export function advanceJob(job: CronJob, now: number): CronJob | undefined {
118
+ if (job.intervalMs === undefined) return undefined;
119
+ return { ...job, nextRunAt: now + job.intervalMs };
120
+ }
121
+
122
+ /** Human-readable duration for listings. */
123
+ export function formatInterval(ms: number) {
124
+ if (ms % UNITS.h === 0) return `${ms / UNITS.h}h`;
125
+ if (ms % UNITS.m === 0) return `${ms / UNITS.m}m`;
126
+ return `${Math.round(ms / UNITS.s)}s`;
127
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ createEditToolDefinition,
4
+ createWriteToolDefinition,
5
+ type ExtensionAPI,
6
+ type ToolDefinition,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { loadSetupConfig } from "../shared/setup-config.ts";
9
+ import {
10
+ compactBashRenderedComponent,
11
+ compactRenderedComponent,
12
+ singleLineRenderedComponent,
13
+ } from "./render.ts";
14
+
15
+ function withCompactCallRenderer(
16
+ definition: ToolDefinition<any, any, any>,
17
+ ): ToolDefinition<any, any, any> {
18
+ const renderCall = definition.renderCall;
19
+ if (!renderCall) return definition;
20
+ return {
21
+ ...definition,
22
+ renderCall(args, theme, context) {
23
+ const component = renderCall(args, theme, context);
24
+ if (
25
+ context.expanded ||
26
+ loadSetupConfig().ui.fileMutationDisplay === "full"
27
+ ) {
28
+ return component;
29
+ }
30
+ // While the model is still streaming a large Write/Edit payload, a
31
+ // syntax-highlighted preview changes on nearly every token and makes the
32
+ // entire tool block flash. Keep that phase to a stable one-line header;
33
+ // reveal the bounded preview once arguments are complete.
34
+ if (!context.argsComplete) {
35
+ return singleLineRenderedComponent(component, theme);
36
+ }
37
+ const background = context.isPartial
38
+ ? (text: string) => theme.bg("toolPendingBg", text)
39
+ : context.isError
40
+ ? (text: string) => theme.bg("toolErrorBg", text)
41
+ : (text: string) => theme.bg("toolSuccessBg", text);
42
+ return compactRenderedComponent(component, theme, undefined, background);
43
+ },
44
+ };
45
+ }
46
+
47
+ function withCompactBashRenderer(
48
+ definition: ToolDefinition<any, any, any>,
49
+ ): ToolDefinition<any, any, any> {
50
+ const renderCall = definition.renderCall;
51
+ const renderResult = definition.renderResult;
52
+ if (!renderCall || !renderResult) return definition;
53
+ return {
54
+ ...definition,
55
+ renderCall(args, theme, context) {
56
+ // A compact wrapper is not the native Text component expected through
57
+ // lastComponent, so rebuild the cheap call renderer on each update.
58
+ const component = renderCall(args, theme, {
59
+ ...context,
60
+ lastComponent: undefined,
61
+ });
62
+ if (context.expanded || loadSetupConfig().ui.bashToolDisplay === "full") {
63
+ return component;
64
+ }
65
+ return singleLineRenderedComponent(component, theme);
66
+ },
67
+ renderResult(result, options, theme, context) {
68
+ // Bash streams partial results. Never hand our wrapper back to Pi's
69
+ // native BashResultRenderComponent updater.
70
+ const component = renderResult(result, options, theme, {
71
+ ...context,
72
+ lastComponent: undefined,
73
+ });
74
+ if (options.expanded || loadSetupConfig().ui.bashToolDisplay === "full") {
75
+ return component;
76
+ }
77
+ return compactBashRenderedComponent(component, theme);
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Override only the TUI renderers. The wrapped definitions are Pi's native
84
+ * Bash/Write/Edit tools, so schemas, execution, mutation queues, diffs, and
85
+ * errors stay on the upstream implementation.
86
+ */
87
+ export default function fileMutationDisplay(pi: ExtensionAPI) {
88
+ pi.on("session_start", (_event, ctx) => {
89
+ const display = loadSetupConfig().ui;
90
+ if (ctx.mode === "tui") {
91
+ // Ctrl+O remains a temporary override. A new/reloaded session starts from
92
+ // the persisted defaults instead of inheriting an old expanded toggle.
93
+ ctx.ui.setToolsExpanded(
94
+ display.subagentResultDisplay === "full" &&
95
+ display.bashToolDisplay === "full" &&
96
+ display.fileMutationDisplay === "full",
97
+ );
98
+ }
99
+ pi.registerTool(withCompactBashRenderer(createBashToolDefinition(ctx.cwd)));
100
+ pi.registerTool(
101
+ withCompactCallRenderer(createWriteToolDefinition(ctx.cwd)),
102
+ );
103
+ pi.registerTool(withCompactCallRenderer(createEditToolDefinition(ctx.cwd)));
104
+ });
105
+ }
@@ -0,0 +1,107 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ type Component,
7
+ } from "@earendil-works/pi-tui";
8
+
9
+ // Native Write/Edit renderings include their title and context rows. Three
10
+ // visible rows keep the operation identifiable while making repeated file
11
+ // mutations substantially quieter than Claude Code's default preview.
12
+ export const FILE_MUTATION_PREVIEW_LINES = 3;
13
+ export const BASH_OUTPUT_PREVIEW_LINES = 1;
14
+
15
+ function expandHint(hidden: number) {
16
+ return `… ${hidden} more line${hidden === 1 ? "" : "s"} · ${keyHint("app.tools.expand", "to expand")}`;
17
+ }
18
+
19
+ /** Keep a long command identifiable without allowing it to wrap for many rows. */
20
+ export function singleLineRenderedComponent(
21
+ component: Component,
22
+ theme: Theme,
23
+ ): Component {
24
+ return {
25
+ render(width) {
26
+ const lines = component.render(width);
27
+ if (lines.length <= 1) return lines;
28
+ if (width <= 2) return [truncateToWidth(lines[0] ?? "", width, "")];
29
+ return [
30
+ truncateToWidth(lines[0] ?? "", width - 2, "") + theme.fg("dim", " …"),
31
+ ];
32
+ },
33
+ invalidate() {
34
+ component.invalidate();
35
+ },
36
+ };
37
+ }
38
+
39
+ /** Preserve one output row, warnings, and final timing in compact Bash mode. */
40
+ export function compactBashRenderedComponent(
41
+ component: Component,
42
+ theme: Theme,
43
+ maximum = BASH_OUTPUT_PREVIEW_LINES,
44
+ ): Component {
45
+ return {
46
+ render(width) {
47
+ const rendered = component.render(width);
48
+ const visible = rendered.filter(
49
+ (line) => stripVTControlCharacters(line).trim().length > 0,
50
+ );
51
+ let nativeHidden = 0;
52
+ const content: string[] = [];
53
+ const metadata: string[] = [];
54
+ let status: string | undefined;
55
+ for (const line of visible) {
56
+ const plain = stripVTControlCharacters(line).trim();
57
+ const hiddenMatch = plain.match(/\(?([0-9]+) earlier lines,/);
58
+ if (hiddenMatch) {
59
+ nativeHidden += Number(hiddenMatch[1]);
60
+ } else if (/^(Took|Elapsed)\s/.test(plain)) {
61
+ status = line;
62
+ } else if (/^\[(Full output|Truncated):/.test(plain)) {
63
+ metadata.push(line);
64
+ } else {
65
+ content.push(line);
66
+ }
67
+ }
68
+ const preview = content.slice(0, maximum);
69
+ const hidden =
70
+ nativeHidden + Math.max(0, content.length - preview.length);
71
+ return [
72
+ ...preview,
73
+ ...(hidden > 0 ? [theme.fg("dim", expandHint(hidden))] : []),
74
+ ...metadata,
75
+ ...(status ? [status] : []),
76
+ ];
77
+ },
78
+ invalidate() {
79
+ component.invalidate();
80
+ },
81
+ };
82
+ }
83
+
84
+ export function compactRenderedComponent(
85
+ component: Component,
86
+ theme: Theme,
87
+ maximum = FILE_MUTATION_PREVIEW_LINES,
88
+ background?: (text: string) => string,
89
+ ): Component {
90
+ return {
91
+ render(width) {
92
+ const lines = component.render(width);
93
+ if (lines.length <= maximum) return lines;
94
+ const hidden = lines.length - maximum;
95
+ const hint = theme.fg("dim", expandHint(hidden));
96
+ const paddedHint =
97
+ hint + " ".repeat(Math.max(0, width - visibleWidth(hint)));
98
+ return [
99
+ ...lines.slice(0, maximum),
100
+ background ? background(paddedHint) : hint,
101
+ ];
102
+ },
103
+ invalidate() {
104
+ component.invalidate();
105
+ },
106
+ };
107
+ }