-
Notifications
You must be signed in to change notification settings - Fork 173
Expand file tree
/
Copy pathshell-bar.ts
More file actions
495 lines (451 loc) · 24.5 KB
/
Copy pathshell-bar.ts
File metadata and controls
495 lines (451 loc) · 24.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
import { renderUsageBar, selectUsageLimit, type ProviderUsage, type UsageWindow } from "./shell-usage.ts";
import { sanitizeTerminalText } from "./terminal-theme.ts";
import { CARD_TONE, floatRows, panelInnerWidth, renderCard } from "./shell-card.ts";
import { REVIEW_SCOPE_UNAVAILABLE, REVIEW_SIDEBAR_LABELS, type ReviewSidebarSnapshot } from "./review-sidebar-state.ts";
import type { VisualSettings } from "./visual-customization-policy.ts";
import { renderChangesWidget, type ChangesModel } from "./shell-changes.ts";
type Presentation = Pick<VisualSettings, "density" | "visibility">;
type HeaderPresentation = Presentation & Partial<Pick<VisualSettings, "headerPlacement" | "statusPlacement">>;
export { gaugeTone, renderGauge, type GaugeTone };
// Gentle Shell status bar: one line of segments that replaces pi's built-in
// three-line footer. Everything here is pure so the bar can be rendered and
// verified without a live TUI.
export interface ShellBarModel {
review?: ReviewSidebarSnapshot;
profile?: string;
changes?: { files: number; added: number; deleted: number; notice?: string };
cwd: string;
branch: string | null;
dirty: number | undefined;
sessionName: string | undefined;
modelId: string;
effort: string | undefined;
contextPercent: number | null;
contextWindow: number;
costTotal: number;
subscription: boolean;
usage: ProviderUsage | undefined;
statuses: string[];
}
// The live header row above the fullscreen rail: session identity plus the
// two counters that tick every frame (context, cost). Deliberately narrower
// than ShellBarModel — extension statuses and the working/thinking state
// never reach the header, so there is nothing on this type for them to leak
// through.
export interface ShellHeaderModel {
cwd: string;
branch: string | null;
dirty: number | undefined;
modelId: string;
effort: string | undefined;
profile?: string;
contextPercent: number | null;
costTotal: number;
subscription: boolean;
// The active provider's subscription usage, shown as its own segment after
// cost. Unlike the sidebar's old per-model usage table, this is one
// compact line — the same windows the compact bar already meters.
usage: ProviderUsage | undefined;
}
export function buildShellHeaderModel(model: ShellBarModel): ShellHeaderModel {
const { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage } = model;
return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage };
}
/** A column span (`[start, end)`, in the rendered line's visible columns) a click must land in to hit the usage segment. */
export interface HeaderUsageSpan {
start: number;
end: number;
}
export interface ShellHeaderResult {
text: string;
usageSpan?: HeaderUsageSpan;
}
export interface ShellHeaderChrome {
rows: string[];
headerRow: number;
usageSpan?: HeaderUsageSpan;
}
export interface ShellBarTheme {
fg(color: string, text: string): string;
bold(text: string): string;
}
// Theme roles the bar paints with. Keys are pi theme colors; the Gentle themes
// map them to the rose palette (accent = rose, syntaxFunction = powder blue).
const ROLE = {
BRAND: "accent",
SEPARATOR: "dim",
PATH: "muted",
BRANCH: "text",
DIRTY: "warning",
MODEL: "text",
EFFORT: "syntaxFunction",
LABEL: "muted",
VALUE: "text",
STATUS: "muted",
SESSION: "dim",
} as const;
export const SHELL_BAR_BRAND = "✿ gentle shell";
export const SHELL_BAR_SEPARATOR = "⟡";
export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
const RIGHT_PADDING = 2;
const COMPACT_BRANCH_WIDTH = 15;
export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
const value = env.GENTLE_PI_SHELL?.trim().toLowerCase();
return !(value === "0" || value === "false" || value === "off");
}
export function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
return `${Math.round(count / 1_000_000)}M`;
}
export function formatCost(total: number, subscription = false): string {
const amount = total >= 1 ? total.toFixed(2) : total.toFixed(3);
return subscription ? `$${amount} sub` : `$${amount}`;
}
// Extensions may paint their status themselves (pi-mcp-adapter does); the bar
// owns the palette, so their escapes go and the text takes the status role.
function sanitizeStatus(text: string): string {
return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim();
}
// Shared by the compact bar, the sidebar Status card, and the fullscreen
// header row, so the three surfaces never drift on how they paint the same
// facts.
function locationSegment(model: Pick<ShellBarModel, "cwd" | "branch" | "dirty">, theme: ShellBarTheme): string {
const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : "";
return model.branch
? `${theme.fg(ROLE.PATH, model.cwd)} ${theme.fg(ROLE.BRANCH, model.branch)}${dirty}`
: theme.fg(ROLE.PATH, model.cwd) + dirty;
}
function executionSegment(modelId: string, effort: string | undefined, theme: ShellBarTheme): string {
return effort
? `${theme.fg(ROLE.MODEL, modelId)} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.EFFORT, effort)}`
: theme.fg(ROLE.MODEL, modelId);
}
function contextSegment(contextPercent: number | null, theme: ShellBarTheme): string {
const percentText = contextPercent === null ? "?%" : `${Math.round(contextPercent)}%`;
return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
}
function costSegment(costTotal: number, subscription: boolean, theme: ShellBarTheme): string {
return theme.fg(ROLE.VALUE, formatCost(costTotal, subscription));
}
function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation): string[] {
const location = locationSegment(model, theme);
const modelSegment = executionSegment(model.modelId, model.effort, theme);
const context = contextSegment(model.contextPercent, theme);
const cost = costSegment(model.costTotal, model.subscription, theme);
const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
return [
...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]),
location,
...(presentation?.visibility.modelDetails === false ? [] : [modelSegment]),
...(presentation?.visibility.usageCost === false ? [] : [context, cost, ...(usage ? [usage] : [])]),
...statuses,
];
}
// When the line overflows, the location gives way first: the path shrinks to
// its last segment and a long branch is clipped, so the trailing statuses
// (MCP servers, extension notices) survive on ordinary terminal widths.
function compactModel(model: ShellBarModel): ShellBarModel {
const cwd = model.cwd.split("/").filter((part) => part.length > 0).pop() ?? model.cwd;
const branch = model.branch && visibleWidth(model.branch) > COMPACT_BRANCH_WIDTH ? clipText(model.branch, COMPACT_BRANCH_WIDTH) : model.branch;
return { ...model, cwd, branch };
}
// Plain clip: pi's truncateToWidth wraps the result in resets, which would end
// up inside a painted segment.
function clipText(text: string, max: number): string {
let clipped = "";
for (const char of text) {
if (visibleWidth(clipped + char) > max - 1) break;
clipped += char;
}
return `${clipped}…`;
}
function joinSegments(segments: string[], theme: ShellBarTheme): string {
return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `);
}
// Sidebar groups use structured fields, never positional compact-bar segments
// or inferred meanings from opaque extension status strings.
export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation): string[] {
const value = (text: string) => theme.fg(ROLE.VALUE, theme.bold(text));
const label = (text: string) => theme.fg(ROLE.LABEL, text);
const changes = model.changes;
const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
// Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
// the same inset without consuming the card's right border.
const innerWidth = panelInnerWidth(theme, width);
const inset = Math.min(1, innerWidth - 1);
// Model, effort, context, cost, and the per-model usage table now live in
// the always-visible header row (and /gentle:usage for the full table);
// this event-driven card keeps only what a footer/model-switch event does
// not already refresh every frame.
const groups: Array<{ title: string; lines: string[] }> = [
{
title: "Project",
lines: [
value(model.cwd),
...(branch ? [branch] : []),
...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []),
...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []),
],
},
...(presentation?.visibility.changes === false ? [] : [{
title: "Changes",
lines: [
changes?.files
? `${changes.files} ${changes.files === 1 ? "file" : "files"} · ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}`
: label("No captured changes"),
...(changes?.notice ? [theme.fg("warning", sanitizeStatus(changes.notice))] : []),
label("/gentle:changes"),
],
}]),
...(model.review && presentation?.visibility.rdd !== false ? [{
title: "🌹 RDD",
lines: [
value(REVIEW_SIDEBAR_LABELS[model.review.state]),
// Unknown scope is an internal sentinel, not something the user acts on.
...(model.review.scope !== REVIEW_SCOPE_UNAVAILABLE ? [label(sanitizeStatus(model.review.scope))] : []),
],
}] : []),
{ title: "Integrations", lines: model.statuses.length
? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
: [label("No status reported")] },
];
// Wrap and indent every group line before it reaches the card, so Unicode and
// ANSI continuation lines keep the same inset without consuming the right border.
const body = groups.flatMap((group, index) => [
...(index && (!presentation || presentation.density === "comfortable") ? [""] : []),
...(presentation?.density === "minimal" ? [] : [label(group.title)]),
...group.lines.flatMap((line) => wrapTextWithAnsi(line, innerWidth - inset).map((part) => " ".repeat(inset) + part)),
]);
return renderCard({ title: "Status", body, tone: CARD_TONE.INFO }, theme, width, { expanded: true, panel: true });
}
const HEADER_BRAND = "✿ Gentle Shell";
// Narrower than the width, widest first: dropping the profile, then the
// effort, then the whole location keeps the brand and the bare model id
// alive as long as anything can still share the row with the right-aligned
// counters.
function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showModelDetails: boolean): string[][] {
const brand = theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND));
const location = locationSegment(model, theme);
if (!showModelDetails) return [[brand, location], [brand]];
const withEffort = executionSegment(model.modelId, model.effort, theme);
const modelOnly = executionSegment(model.modelId, undefined, theme);
const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, sanitizeStatus(model.profile))}` : withEffort;
return [
[brand, location, withProfile],
[brand, location, withEffort],
[brand, location, modelOnly],
[brand, modelOnly],
[brand],
];
}
const USAGE_LABEL_ROLE = ROLE.LABEL;
const USAGE_HINT_ROLE = "dim";
function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean): string {
const percent = `${Math.round(window.usedPercent)}%`;
const parts = [
...(window.label.length > 0 ? [theme.fg(ROLE.LABEL, window.label)] : []),
...(withGauge ? [paintGauge(window.usedPercent, theme)] : []),
theme.fg(ROLE.VALUE, percent),
];
return parts.join(" ");
}
// Three degrading shapes for the same windows, narrowest last: every window
// with its gauge, every window as text only, or just the first window as
// text only. A provider with no usage data at all has no windows to shape,
// so all three collapse to the bare "usage" label plus the shortcut hint.
type UsageStage = "full" | "text" | "primary";
function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined): string {
const label = theme.fg(USAGE_LABEL_ROLE, "usage");
const shown = stage === "primary" ? windows.slice(0, 1) : windows;
const body = shown.map((window) => usageWindowText(window, theme, stage === "full")).join(` ${theme.fg(ROLE.LABEL, "·")} `);
const head = body.length > 0 ? `${label} ${body}` : label;
return hint ? `${head} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(USAGE_HINT_ROLE, hint)}` : head;
}
// The float header is a full-width INFO background bar with a two-column
// inset on each side, closed below by the header edge line. floatRows paints
// the background inside one-column transparent margins; those margin cells
// are painted too, so the bar reaches both edges. Undefined for neon, a width
// under the float minimum, or a theme without a background.
function floatHeaderRow(theme: ShellBarTheme, width: number, content: (width: number) => string): string | undefined {
const painted = floatHeaderPaint(theme, width, content);
if (painted === undefined) return undefined;
const { inner, open } = painted;
// The background spans the full width: the margin cells are painted too, so
// the bar reaches both edges with no frame on its sides.
const side = `${open} `;
return `${side}${inner.slice(open.length, -BG_RESET.length)}${side}${BG_RESET}`;
}
function fitHeaderContent(text: string, width: number): string {
const clipped = visibleWidth(text) <= width ? text : truncateToWidth(text, width, "…");
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
}
export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
const targetWidth = Math.max(0, Math.floor(width));
let content: ShellHeaderResult | undefined;
let contentWidth = targetWidth;
const row = floatHeaderRow(theme, targetWidth, (inner) => {
contentWidth = inner;
content = headerContent(model, theme, inner, usageHint, presentation);
return content.text;
});
if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation);
const { usageSpan } = content;
// The inset splits evenly around the content, so its left share is where
// the content's columns start.
const offset = (targetWidth - contentWidth) / 2;
return usageSpan ? { text: row, usageSpan: { start: usageSpan.start + offset, end: usageSpan.end + offset } } : { text: row };
}
/** Shared geometry for the rail header and the below-editor header widget. */
export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation): ShellHeaderChrome {
const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation);
const targetWidth = Math.max(0, Math.floor(width));
const painted = floatHeaderPaint(theme, targetWidth, () => "");
const rule = renderShellHeaderRule(theme, targetWidth);
if (painted === undefined) return { rows: [text, rule], headerRow: 0, usageSpan };
const padding = `${painted.open}${" ".repeat(targetWidth)}${BG_RESET}`;
if (presentation?.headerPlacement === "below-input") {
// A lower one-eighth block hugs the painted padding in the next row.
const upperEdge = theme.fg(HEADER_RULE_ROLE, "▁".repeat(targetWidth));
return { rows: [upperEdge, padding, text, padding], headerRow: 2, usageSpan };
}
return { rows: [padding, text, padding, rule], headerRow: 1, usageSpan };
}
/** The usage segment is interactive only on the content row, never padding or the edge. */
export function shellHeaderUsageHit(chrome: ShellHeaderChrome, x: number, y: number): boolean {
const { usageSpan } = chrome;
return y === chrome.headerRow && usageSpan !== undefined && x >= usageSpan.start && x < usageSpan.end;
}
function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
const targetWidth = Math.max(0, Math.floor(width));
const ctxCost = joinSegments([contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)], theme);
const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : [];
const leftStages = headerLeftStages(model, theme, presentation?.visibility.modelDetails !== false)
.map((stage) => presentation?.density === "minimal" ? stage.slice(1) : stage);
const minimalLeft = Math.max(0, leftStages.length - 2); // brand + bare model id, before dropping the model too
// One flat, ordered cascade — never a per-stage nested search — so the
// left group fully degrades (profile → effort → location) before usage
// ever gives anything up, and usage fully degrades (gauges → secondary
// windows → the whole segment) before ctx/cost is touched: every window
// with its gauge, then text-only, then the first window only, then gone.
// Unlike the compact bar's usage meter (hidden with no data), this is a
// standing, clickable affordance — even with nothing to report it still
// reads "usage" plus the shortcut hint, unless disabled, width permitting.
const usageStages: Array<UsageStage | undefined> = presentation?.visibility.usageCost === false ? [undefined] : ["full", "text", "primary", undefined];
const attempts: Array<{ leftIndex: number; usageStage: UsageStage | undefined }> = [
...leftStages.slice(0, minimalLeft).map((_, leftIndex) => ({ leftIndex, usageStage: usageStages[0] })),
...usageStages.map((usageStage) => ({ leftIndex: minimalLeft, usageStage })),
{ leftIndex: leftStages.length - 1, usageStage: undefined },
];
for (const { leftIndex, usageStage } of attempts) {
const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint) : undefined;
const right = presentation?.visibility.usageCost === false ? "" : usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
const left = joinSegments(leftStages[leftIndex]!, theme);
if (visibleWidth(left) + (right ? RIGHT_PADDING : 0) + visibleWidth(right) > targetWidth) continue;
const text = left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(right)) + right;
if (!usageText) return { text };
const usageStart = visibleWidth(left) + (targetWidth - visibleWidth(left) - visibleWidth(right)) + visibleWidth(ctxCost) + visibleWidth(` ${SHELL_BAR_SEPARATOR} `);
return { text, usageSpan: { start: usageStart, end: usageStart + visibleWidth(usageText) } };
}
const brand = presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND));
return { text: visibleWidth(brand) <= targetWidth ? brand : "" };
}
// A single owner supplies real captured filenames, not ShellBarModel's totals.
// Undefined keeps every legacy surface alive for style/width/theme transitions.
export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): ShellHeaderChrome | undefined {
if (presentation?.headerPlacement !== "below-input") return undefined;
const targetWidth = Math.max(0, Math.floor(width));
if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined;
const chrome = renderShellHeaderChrome(buildShellHeaderModel(model), theme, targetWidth, usageHint, presentation);
const changesRow = changes?.files.length && presentation.visibility.changes !== false
? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "")
: undefined;
const statuses = presentation.statusPlacement === "hidden" ? [] : model.statuses.map(sanitizeStatus).filter(Boolean);
const statusRow = statuses.length
? floatHeaderRow(theme, targetWidth, () => joinSegments(statuses.map((status) => theme.fg(ROLE.STATUS, status)), theme))
: undefined;
return {
rows: [
chrome.rows[0]!,
chrome.rows[1]!,
...(changesRow ? [changesRow] : []),
chrome.rows[2]!,
...(statusRow ? [statusRow] : []),
chrome.rows[3]!,
],
headerRow: changesRow ? 3 : 2,
usageSpan: chrome.usageSpan,
};
}
// The bottom bar when it is the only status row of a narrow fullscreen
// terminal (the header sits below the input and steps aside). It reuses the
// header row's own cascade, so context, cost and usage outlive the location
// on small screens, and keeps extension statuses on a second line instead of
// dropping them the way the header deliberately does.
export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): string[] {
const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes);
if (grouped) return grouped.rows;
const headerModel = buildShellHeaderModel(model);
// Neon, missing backgrounds and sub-minimum widths keep the old row count.
const rows = [headerContent(headerModel, theme, width, usageHint, presentation).text];
const statuses = model.statuses.map(sanitizeStatus).filter((status) => status.length > 0).map((status) => theme.fg(ROLE.STATUS, status));
return statuses.length ? [...rows, truncateToWidth(joinSegments(statuses, theme), Math.max(0, Math.floor(width)), "…")] : rows;
}
// The rule row painted directly under the header bar: one full-width horizontal
// line in the same theme role as the editor frame (PROMPT_FRAME_ROLE in
// extensions/gentle-shell.ts), so the status row and the prompt read as one
// panel. It exists only while the fullscreen sidebar is active — when the
// sidebar is not shown the header rail never renders and the rule goes away
// with it.
const HEADER_RULE_CHAR = "─";
const HEADER_RULE_ROLE = "border";
const HEADER_EDGE_CHAR = "▔";
const BG_RESET = "\x1b[49m";
// Paints the header content with floatRows and returns the row without its
// transparent margins plus the background opener. The painted row is
// `<open> <content> <reset>`; the opener has no spaces, so the first space ends
// it. Undefined when the float style does not apply.
function floatHeaderPaint(theme: ShellBarTheme, width: number, content: (width: number) => string): { inner: string; open: string } | undefined {
let floated = false;
const [row] = floatRows(CARD_TONE.INFO, theme, width, (inner) => {
floated = inner !== width;
return floated ? { body: [` ${fitHeaderContent(content(inner - 2), inner - 2)} `] } : {};
});
if (!floated || row === undefined) return undefined;
const inner = row.slice(1, -1);
return { inner, open: inner.slice(0, inner.indexOf(" ")) };
}
// In the float style the rule closes the hanging header tab with an upper
// one-eighth block line. Box-drawing lines sit mid-cell: a transparent `└──┘`
// leaves half an unpainted row under the tab, and a painted one overshoots it.
// `▔` hugs the top of its cell, so the line touches the tab's background
// exactly. The edge stays transparent beneath the painted bottom padding.
export function renderShellHeaderRule(theme: ShellBarTheme, width: number): string {
const targetWidth = Math.max(0, Math.floor(width));
if (floatHeaderPaint(theme, targetWidth, () => "") !== undefined) return theme.fg(HEADER_RULE_ROLE, HEADER_EDGE_CHAR.repeat(targetWidth));
return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(targetWidth));
}
export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation): string[] {
let segments = buildSegments(model, theme, presentation);
const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
let left = joinSegments(segments, theme);
if (right && visibleWidth(left) + RIGHT_PADDING + visibleWidth(right) <= width) {
const padding = " ".repeat(width - visibleWidth(left) - visibleWidth(right));
return [left + padding + right];
}
if (visibleWidth(left) > width) {
segments = buildSegments(compactModel(model), theme, presentation);
left = joinSegments(segments, theme);
}
while (segments.length > 1 && visibleWidth(left) > width) {
segments.pop();
left = joinSegments(segments, theme);
}
return [truncateToWidth(left, width, "…")];
}