@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.
- package/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
getCapabilities,
|
|
6
|
+
hyperlink,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import type {
|
|
11
|
+
FooterItem,
|
|
12
|
+
FooterLayoutItem,
|
|
13
|
+
FooterLines,
|
|
14
|
+
FooterStyle,
|
|
15
|
+
} from "../shared/setup-config.ts";
|
|
16
|
+
import type {
|
|
17
|
+
GitInfoState,
|
|
18
|
+
ModelInfoState,
|
|
19
|
+
} from "../shared/dashboard-state.ts";
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
const OSC_PATTERN =
|
|
23
|
+
/(?:\u001b\]|\u009d)(?:[^\u0007\u001b\u009c]|\u001b(?!\\))*(?:\u0007|\u001b\\|\u009c)/g;
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
const CSI_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
|
|
28
|
+
|
|
29
|
+
const RESET = "\x1b[0m";
|
|
30
|
+
const POWERLINE_ARROW = "\ue0b0"; // — decorative; text remains readable without Nerd Font
|
|
31
|
+
|
|
32
|
+
/** Higher = keep longer when the line is too narrow. */
|
|
33
|
+
const PRIORITY: Record<FooterItem, number> = {
|
|
34
|
+
cwd: 100,
|
|
35
|
+
model: 95,
|
|
36
|
+
context: 90,
|
|
37
|
+
git: 80,
|
|
38
|
+
pr: 75,
|
|
39
|
+
thinking: 70,
|
|
40
|
+
cost: 40,
|
|
41
|
+
cache: 35,
|
|
42
|
+
throughput: 30,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface PowerlineColors {
|
|
46
|
+
fg: number;
|
|
47
|
+
bg: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const POWERLINE_COLORS: Record<FooterItem, PowerlineColors> = {
|
|
51
|
+
cwd: { fg: 231, bg: 33 },
|
|
52
|
+
model: { fg: 231, bg: 61 },
|
|
53
|
+
thinking: { fg: 231, bg: 97 },
|
|
54
|
+
context: { fg: 231, bg: 64 },
|
|
55
|
+
cache: { fg: 16, bg: 37 },
|
|
56
|
+
cost: { fg: 16, bg: 136 },
|
|
57
|
+
throughput: { fg: 231, bg: 66 },
|
|
58
|
+
git: { fg: 231, bg: 239 },
|
|
59
|
+
pr: { fg: 231, bg: 25 },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const MONO_COLORS: readonly PowerlineColors[] = [
|
|
63
|
+
{ fg: 231, bg: 240 },
|
|
64
|
+
{ fg: 16, bg: 252 },
|
|
65
|
+
{ fg: 231, bg: 236 },
|
|
66
|
+
{ fg: 16, bg: 250 },
|
|
67
|
+
{ fg: 231, bg: 238 },
|
|
68
|
+
{ fg: 16, bg: 248 },
|
|
69
|
+
{ fg: 231, bg: 242 },
|
|
70
|
+
{ fg: 16, bg: 254 },
|
|
71
|
+
{ fg: 231, bg: 244 },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export type SegmentTone =
|
|
75
|
+
"text" | "muted" | "dim" | "warning" | "error" | "accent";
|
|
76
|
+
|
|
77
|
+
export interface FooterSegment {
|
|
78
|
+
readonly id: FooterItem;
|
|
79
|
+
readonly text: string;
|
|
80
|
+
readonly priority: number;
|
|
81
|
+
readonly tone: SegmentTone;
|
|
82
|
+
readonly fg: number;
|
|
83
|
+
readonly bg: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FooterRenderOptions {
|
|
87
|
+
readonly cwd: string;
|
|
88
|
+
readonly modelInfo: ModelInfoState;
|
|
89
|
+
readonly gitInfo: GitInfoState;
|
|
90
|
+
readonly style: FooterStyle;
|
|
91
|
+
readonly lines: FooterLines;
|
|
92
|
+
readonly width: number;
|
|
93
|
+
readonly theme: Pick<Theme, "fg">;
|
|
94
|
+
readonly formatPullRequest?: (number: number, url: string) => string;
|
|
95
|
+
readonly statuses?: Iterable<string>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitizeTerminalLabel(text: string) {
|
|
99
|
+
return text
|
|
100
|
+
.replace(OSC_PATTERN, "")
|
|
101
|
+
.replace(CSI_PATTERN, "")
|
|
102
|
+
.replace(ESCAPE_PATTERN, "")
|
|
103
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatTokens(tokens: number) {
|
|
107
|
+
if (tokens < 1_000) return `${tokens}`;
|
|
108
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
|
|
109
|
+
return `${(tokens / 1_000_000).toFixed(1)}m`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatDirectory(cwd: string) {
|
|
113
|
+
const home = homedir();
|
|
114
|
+
if (cwd === home) return "~";
|
|
115
|
+
const display = cwd.startsWith(`${home}/`) ? `~/${relative(home, cwd)}` : cwd;
|
|
116
|
+
return sanitizeTerminalLabel(display);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function contextTone(percent: number | null): SegmentTone {
|
|
120
|
+
if (percent === null) return "muted";
|
|
121
|
+
if (percent >= 90) return "error";
|
|
122
|
+
if (percent >= 70) return "warning";
|
|
123
|
+
return "muted";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function contextBg(percent: number | null, base: number) {
|
|
127
|
+
if (percent === null) return base;
|
|
128
|
+
if (percent >= 90) return 160;
|
|
129
|
+
if (percent >= 70) return 178;
|
|
130
|
+
return base;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ansi256(fg: number, bg: number, text: string) {
|
|
134
|
+
return `\x1b[38;5;${fg};48;5;${bg}m${text}${RESET}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ansi256FgBg(fg: number, bg: number | null, text: string) {
|
|
138
|
+
if (bg === null) return `\x1b[38;5;${fg}m${text}${RESET}`;
|
|
139
|
+
return `\x1b[38;5;${fg};48;5;${bg}m${text}${RESET}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function defaultPullRequest(number: number, url: string) {
|
|
143
|
+
const label = `PR #${number}`;
|
|
144
|
+
return getCapabilities().hyperlinks ? hyperlink(label, url) : label;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the value catalog for every FooterItem. Empty text means "not available
|
|
149
|
+
* right now" and the segment is omitted from the line.
|
|
150
|
+
*/
|
|
151
|
+
export function buildSegmentCatalog(
|
|
152
|
+
cwd: string,
|
|
153
|
+
modelInfo: ModelInfoState,
|
|
154
|
+
gitInfo: GitInfoState,
|
|
155
|
+
formatPullRequest: (
|
|
156
|
+
number: number,
|
|
157
|
+
url: string,
|
|
158
|
+
) => string = defaultPullRequest,
|
|
159
|
+
): Record<FooterItem, { text: string; tone: SegmentTone }> {
|
|
160
|
+
const contextWindow =
|
|
161
|
+
modelInfo.contextWindow > 0 ? formatTokens(modelInfo.contextWindow) : "";
|
|
162
|
+
const contextText =
|
|
163
|
+
modelInfo.contextPercent === null
|
|
164
|
+
? contextWindow
|
|
165
|
+
? // Occupancy is unknown right after compaction, until the next reply.
|
|
166
|
+
`?%/${contextWindow}`
|
|
167
|
+
: ""
|
|
168
|
+
: `${Math.round(modelInfo.contextPercent)}%${contextWindow ? `/${contextWindow}` : ""}`;
|
|
169
|
+
|
|
170
|
+
const modelText = modelInfo.provider
|
|
171
|
+
? `${modelInfo.provider}/${modelInfo.modelId}`
|
|
172
|
+
: modelInfo.modelId;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
cwd: { text: formatDirectory(cwd), tone: "text" },
|
|
176
|
+
model: { text: modelText, tone: "muted" },
|
|
177
|
+
thinking: { text: modelInfo.thinking, tone: "muted" },
|
|
178
|
+
context: {
|
|
179
|
+
text: contextText,
|
|
180
|
+
tone: contextTone(modelInfo.contextPercent),
|
|
181
|
+
},
|
|
182
|
+
cache: {
|
|
183
|
+
text:
|
|
184
|
+
modelInfo.cachePercent !== null
|
|
185
|
+
? `cache ${Math.round(modelInfo.cachePercent)}%`
|
|
186
|
+
: "",
|
|
187
|
+
tone: "muted",
|
|
188
|
+
},
|
|
189
|
+
cost: { text: `$${modelInfo.cost.toFixed(2)}`, tone: "muted" },
|
|
190
|
+
throughput: {
|
|
191
|
+
text:
|
|
192
|
+
modelInfo.tokensPerSecond === null
|
|
193
|
+
? "— tok/s"
|
|
194
|
+
: `~${Math.round(modelInfo.tokensPerSecond)} tok/s`,
|
|
195
|
+
tone: "muted",
|
|
196
|
+
},
|
|
197
|
+
git: { text: gitInfo.branch ?? "", tone: "muted" },
|
|
198
|
+
pr: {
|
|
199
|
+
text: gitInfo.pullRequest
|
|
200
|
+
? formatPullRequest(gitInfo.pullRequest.number, gitInfo.pullRequest.url)
|
|
201
|
+
: "",
|
|
202
|
+
tone: "accent",
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function colorsFor(
|
|
208
|
+
id: FooterItem,
|
|
209
|
+
style: FooterStyle,
|
|
210
|
+
index: number,
|
|
211
|
+
contextPercent: number | null,
|
|
212
|
+
): PowerlineColors {
|
|
213
|
+
if (style === "powerline-mono") {
|
|
214
|
+
return MONO_COLORS[index % MONO_COLORS.length]!;
|
|
215
|
+
}
|
|
216
|
+
const base = POWERLINE_COLORS[id];
|
|
217
|
+
if (id === "context") {
|
|
218
|
+
return { fg: base.fg, bg: contextBg(contextPercent, base.bg) };
|
|
219
|
+
}
|
|
220
|
+
return base;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function resolveLineSegments(
|
|
224
|
+
layout: readonly FooterLayoutItem[],
|
|
225
|
+
catalog: ReturnType<typeof buildSegmentCatalog>,
|
|
226
|
+
style: FooterStyle,
|
|
227
|
+
contextPercent: number | null,
|
|
228
|
+
): { left: FooterSegment[]; right: FooterSegment[] } {
|
|
229
|
+
const left: FooterSegment[] = [];
|
|
230
|
+
const right: FooterSegment[] = [];
|
|
231
|
+
let side: FooterSegment[] = left;
|
|
232
|
+
let colorIndex = 0;
|
|
233
|
+
|
|
234
|
+
for (const item of layout) {
|
|
235
|
+
if (item === "flex") {
|
|
236
|
+
side = right;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const entry = catalog[item];
|
|
240
|
+
if (!entry.text) continue;
|
|
241
|
+
const colors = colorsFor(item, style, colorIndex, contextPercent);
|
|
242
|
+
colorIndex += 1;
|
|
243
|
+
side.push({
|
|
244
|
+
id: item,
|
|
245
|
+
text: entry.text,
|
|
246
|
+
priority: PRIORITY[item],
|
|
247
|
+
tone: entry.tone,
|
|
248
|
+
fg: colors.fg,
|
|
249
|
+
bg: colors.bg,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { left, right };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function paintPlain(segment: FooterSegment, theme: Pick<Theme, "fg">) {
|
|
257
|
+
return theme.fg(segment.tone, segment.text);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function joinPlain(
|
|
261
|
+
segments: readonly FooterSegment[],
|
|
262
|
+
theme: Pick<Theme, "fg">,
|
|
263
|
+
) {
|
|
264
|
+
return segments
|
|
265
|
+
.map((segment) => paintPlain(segment, theme))
|
|
266
|
+
.join(theme.fg("dim", " · "));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderPowerlineSide(segments: readonly FooterSegment[]) {
|
|
270
|
+
if (segments.length === 0) return "";
|
|
271
|
+
|
|
272
|
+
let output = "";
|
|
273
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
274
|
+
const current = segments[index]!;
|
|
275
|
+
output += ansi256(current.fg, current.bg, ` ${current.text} `);
|
|
276
|
+
const next = segments[index + 1];
|
|
277
|
+
if (next) {
|
|
278
|
+
// Arrow inherits previous bg as fg and next bg as bg — classic powerline seam.
|
|
279
|
+
output += ansi256FgBg(current.bg, next.bg, POWERLINE_ARROW);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const last = segments[segments.length - 1]!;
|
|
283
|
+
output += ansi256FgBg(last.bg, null, POWERLINE_ARROW);
|
|
284
|
+
return output;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderPowerlineLine(
|
|
288
|
+
left: readonly FooterSegment[],
|
|
289
|
+
right: readonly FooterSegment[],
|
|
290
|
+
width: number,
|
|
291
|
+
) {
|
|
292
|
+
const leftText = renderPowerlineSide(left);
|
|
293
|
+
const rightText = renderPowerlineSide(right);
|
|
294
|
+
if (!rightText) return truncateToWidth(leftText, width);
|
|
295
|
+
if (!leftText) return truncateToWidth(rightText, width);
|
|
296
|
+
|
|
297
|
+
const gap = width - visibleWidth(leftText) - visibleWidth(rightText);
|
|
298
|
+
if (gap >= 1) {
|
|
299
|
+
return truncateToWidth(`${leftText}${" ".repeat(gap)}${rightText}`, width);
|
|
300
|
+
}
|
|
301
|
+
return truncateToWidth(`${leftText}${" ".repeat(1)}${rightText}`, width);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderPlainLine(
|
|
305
|
+
left: readonly FooterSegment[],
|
|
306
|
+
right: readonly FooterSegment[],
|
|
307
|
+
width: number,
|
|
308
|
+
theme: Pick<Theme, "fg">,
|
|
309
|
+
) {
|
|
310
|
+
const leftText = joinPlain(left, theme);
|
|
311
|
+
const rightText = joinPlain(right, theme);
|
|
312
|
+
if (!rightText) return truncateToWidth(leftText, width);
|
|
313
|
+
if (!leftText) return truncateToWidth(rightText, width);
|
|
314
|
+
|
|
315
|
+
const naturalGap = width - visibleWidth(leftText) - visibleWidth(rightText);
|
|
316
|
+
if (naturalGap >= 1) {
|
|
317
|
+
return `${leftText}${" ".repeat(naturalGap)}${rightText}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const leftWidth = Math.max(1, Math.floor(width * 0.45));
|
|
321
|
+
const rightWidth = Math.max(1, width - leftWidth - 1);
|
|
322
|
+
const fittedLeft = truncateToWidth(leftText, leftWidth);
|
|
323
|
+
const fittedRight = truncateToWidth(rightText, rightWidth);
|
|
324
|
+
const gap = Math.max(
|
|
325
|
+
1,
|
|
326
|
+
width - visibleWidth(fittedLeft) - visibleWidth(fittedRight),
|
|
327
|
+
);
|
|
328
|
+
return truncateToWidth(
|
|
329
|
+
`${fittedLeft}${" ".repeat(gap)}${fittedRight}`,
|
|
330
|
+
width,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function naturalLineWidth(
|
|
335
|
+
left: readonly FooterSegment[],
|
|
336
|
+
right: readonly FooterSegment[],
|
|
337
|
+
style: FooterStyle,
|
|
338
|
+
theme: Pick<Theme, "fg">,
|
|
339
|
+
) {
|
|
340
|
+
if (style === "plain") {
|
|
341
|
+
const leftText = joinPlain(left, theme);
|
|
342
|
+
const rightText = joinPlain(right, theme);
|
|
343
|
+
if (!rightText) return visibleWidth(leftText);
|
|
344
|
+
if (!leftText) return visibleWidth(rightText);
|
|
345
|
+
return visibleWidth(leftText) + 1 + visibleWidth(rightText);
|
|
346
|
+
}
|
|
347
|
+
const leftText = renderPowerlineSide(left);
|
|
348
|
+
const rightText = renderPowerlineSide(right);
|
|
349
|
+
if (!rightText) return visibleWidth(leftText);
|
|
350
|
+
if (!leftText) return visibleWidth(rightText);
|
|
351
|
+
return visibleWidth(leftText) + 1 + visibleWidth(rightText);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Drop lowest-priority optional segments until the line fits, keeping at least
|
|
356
|
+
* one segment on each non-empty side. Final safety truncate is still applied.
|
|
357
|
+
*/
|
|
358
|
+
export function fitSegmentsToWidth(
|
|
359
|
+
left: readonly FooterSegment[],
|
|
360
|
+
right: readonly FooterSegment[],
|
|
361
|
+
width: number,
|
|
362
|
+
style: FooterStyle,
|
|
363
|
+
theme: Pick<Theme, "fg">,
|
|
364
|
+
): { left: FooterSegment[]; right: FooterSegment[] } {
|
|
365
|
+
let nextLeft = [...left];
|
|
366
|
+
let nextRight = [...right];
|
|
367
|
+
|
|
368
|
+
const fits = () =>
|
|
369
|
+
naturalLineWidth(nextLeft, nextRight, style, theme) <= width;
|
|
370
|
+
|
|
371
|
+
while (!fits()) {
|
|
372
|
+
const candidates: {
|
|
373
|
+
side: "left" | "right";
|
|
374
|
+
index: number;
|
|
375
|
+
priority: number;
|
|
376
|
+
}[] = [];
|
|
377
|
+
if (nextLeft.length > 1) {
|
|
378
|
+
for (let index = 0; index < nextLeft.length; index += 1) {
|
|
379
|
+
candidates.push({
|
|
380
|
+
side: "left",
|
|
381
|
+
index,
|
|
382
|
+
priority: nextLeft[index]!.priority,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
} else if (nextLeft.length === 1 && nextRight.length === 0) {
|
|
386
|
+
// Single side with one segment — stop hiding; truncate will handle it.
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
if (nextRight.length > 1) {
|
|
390
|
+
for (let index = 0; index < nextRight.length; index += 1) {
|
|
391
|
+
candidates.push({
|
|
392
|
+
side: "right",
|
|
393
|
+
index,
|
|
394
|
+
priority: nextRight[index]!.priority,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
} else if (nextRight.length === 1 && nextLeft.length === 0) {
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// When each side has exactly one segment, we can still drop from the
|
|
402
|
+
// lower-priority side if the other side remains non-empty… but the rule
|
|
403
|
+
// is "each non-empty side keeps ≥1", so with one each we stop.
|
|
404
|
+
if (candidates.length === 0) break;
|
|
405
|
+
|
|
406
|
+
candidates.sort((a, b) => a.priority - b.priority || b.index - a.index);
|
|
407
|
+
const victim = candidates[0]!;
|
|
408
|
+
if (victim.side === "left") nextLeft.splice(victim.index, 1);
|
|
409
|
+
else nextRight.splice(victim.index, 1);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { left: nextLeft, right: nextRight };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function renderFooterLine(
|
|
416
|
+
layout: readonly FooterLayoutItem[],
|
|
417
|
+
catalog: ReturnType<typeof buildSegmentCatalog>,
|
|
418
|
+
style: FooterStyle,
|
|
419
|
+
width: number,
|
|
420
|
+
theme: Pick<Theme, "fg">,
|
|
421
|
+
contextPercent: number | null,
|
|
422
|
+
) {
|
|
423
|
+
const resolved = resolveLineSegments(layout, catalog, style, contextPercent);
|
|
424
|
+
const fitted = fitSegmentsToWidth(
|
|
425
|
+
resolved.left,
|
|
426
|
+
resolved.right,
|
|
427
|
+
width,
|
|
428
|
+
style,
|
|
429
|
+
theme,
|
|
430
|
+
);
|
|
431
|
+
if (fitted.left.length === 0 && fitted.right.length === 0) return "";
|
|
432
|
+
|
|
433
|
+
if (style === "plain") {
|
|
434
|
+
return renderPlainLine(fitted.left, fitted.right, width, theme);
|
|
435
|
+
}
|
|
436
|
+
return renderPowerlineLine(fitted.left, fitted.right, width);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function renderFooter(options: FooterRenderOptions) {
|
|
440
|
+
const formatPullRequest = options.formatPullRequest ?? defaultPullRequest;
|
|
441
|
+
const catalog = buildSegmentCatalog(
|
|
442
|
+
options.cwd,
|
|
443
|
+
options.modelInfo,
|
|
444
|
+
options.gitInfo,
|
|
445
|
+
formatPullRequest,
|
|
446
|
+
);
|
|
447
|
+
const lines: string[] = [];
|
|
448
|
+
|
|
449
|
+
for (const layout of options.lines) {
|
|
450
|
+
const line = renderFooterLine(
|
|
451
|
+
layout,
|
|
452
|
+
catalog,
|
|
453
|
+
options.style,
|
|
454
|
+
options.width,
|
|
455
|
+
options.theme,
|
|
456
|
+
options.modelInfo.contextPercent,
|
|
457
|
+
);
|
|
458
|
+
if (line) lines.push(line);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (options.statuses) {
|
|
462
|
+
for (const statusLine of options.statuses) {
|
|
463
|
+
for (const part of statusLine.split("\n")) {
|
|
464
|
+
lines.push(
|
|
465
|
+
truncateToWidth(part, options.width, options.theme.fg("dim", "...")),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return lines;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Legacy helper retained for tests that assert grouped content. */
|
|
475
|
+
export function buildFooterContent(
|
|
476
|
+
modelInfo: ModelInfoState,
|
|
477
|
+
gitInfo: GitInfoState,
|
|
478
|
+
selectedItems: readonly FooterItem[],
|
|
479
|
+
formatPullRequest: (number: number, url: string) => string = (number) =>
|
|
480
|
+
`PR #${number}`,
|
|
481
|
+
) {
|
|
482
|
+
const items = new Set(selectedItems);
|
|
483
|
+
const catalog = buildSegmentCatalog(
|
|
484
|
+
"",
|
|
485
|
+
modelInfo,
|
|
486
|
+
gitInfo,
|
|
487
|
+
formatPullRequest,
|
|
488
|
+
);
|
|
489
|
+
return {
|
|
490
|
+
showCwd: items.has("cwd"),
|
|
491
|
+
model: [
|
|
492
|
+
items.has("model") ? catalog.model.text : "",
|
|
493
|
+
items.has("thinking") ? catalog.thinking.text : "",
|
|
494
|
+
]
|
|
495
|
+
.filter(Boolean)
|
|
496
|
+
.join(" · "),
|
|
497
|
+
usage: [
|
|
498
|
+
items.has("context") ? catalog.context.text : "",
|
|
499
|
+
items.has("cache") ? catalog.cache.text : "",
|
|
500
|
+
items.has("cost") ? catalog.cost.text : "",
|
|
501
|
+
items.has("throughput") ? catalog.throughput.text : "",
|
|
502
|
+
]
|
|
503
|
+
.filter(Boolean)
|
|
504
|
+
.join(" · "),
|
|
505
|
+
git: [
|
|
506
|
+
items.has("git") ? catalog.git.text : "",
|
|
507
|
+
items.has("pr") ? catalog.pr.text : "",
|
|
508
|
+
]
|
|
509
|
+
.filter(Boolean)
|
|
510
|
+
.join(" · "),
|
|
511
|
+
};
|
|
512
|
+
}
|