Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions extensions/gentle-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { statSync } from "node:fs";
import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
import * as os from "node:os";
import { join } from "node:path";
import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
Expand Down Expand Up @@ -906,10 +906,11 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
const disposeHeader = sidebarHeader(tui, {
digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
render: (width) => [headerBar(width).text],
render: (width) => [headerBar(width).text, renderShellHeaderRule(theme, width)],
invalidate() {},
handleMouse(event) {
if (event.type !== "click" || event.button !== "left") return undefined;
if (event.y !== 0) return undefined; // the rule row under the status line is decorative, never clickable
const { usageSpan } = headerBar(event.width);
if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
void openUsage(ctx);
Expand Down
13 changes: 13 additions & 0 deletions lib/shell-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,19 @@ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarThe
return { text: visibleWidth(brand) <= targetWidth ? brand : "" };
}

// 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";

export function renderShellHeaderRule(theme: ShellBarTheme, width: number): string {
return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(Math.max(0, Math.floor(width))));
}

export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
let segments = buildSegments(model, theme);
const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
Expand Down
8 changes: 5 additions & 3 deletions lib/shell-sidebar-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
for (const part of state.parts.values()) part.invalidate();
},
};
// The header row: a plain leaf component, one line tall, painted above the
// hstack when a "header" part is registered and has something to show.
// The header row: a plain leaf component measured from its rendered lines
// (one line with just the status bar; two once the rule row joins it),
// painted full-width above the hstack when a "header" part is registered
// and has something to show.
// The header is not inside the rail's ScrollView, so it never goes through
// dispatchPartMouse: it is its own leaf in the layout tree (no [NODE]),
// and pi-tui's mouse dispatch (tui-alt-screen.js dispatchMouseToLayout)
Expand Down Expand Up @@ -277,7 +279,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
if (current.presentation?.scrollTop === scroll.scrollTop) return current.presentation.output;
const output: LayoutNode = current.headerActive
? { type: "vstack", gap: 0, align: "stretch", entries: [
{ component: header, basis: 1, grow: 0, shrink: 0, minSize: 1 },
{ component: header, basis: "auto", grow: 0, shrink: 0, minSize: 1 },
{ component: hstackHost, basis: 0, grow: 1, shrink: 1, minSize: 1 },
] }
: hstackHost[NODE]();
Expand Down
8 changes: 8 additions & 0 deletions tests/shell-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
renderGauge,
renderShellBar,
renderShellHeaderBar,
renderShellHeaderRule,
renderShellSidebarBar,
shellEnabled,
type ShellBarModel,
Expand Down Expand Up @@ -427,3 +428,10 @@ test("renderShellHeaderBar's usage span always points at the usage text, not ctx
assert.equal(usageSpan.end, visibleWidth(text), "the usage segment always ends at the right edge");
}
});

test("renderShellHeaderRule paints one full-width line in the editor frame color", () => {
assert.equal(renderShellHeaderRule(taggedTheme, 4), "<border>────</border>", "the rule uses the editor frame's border role");
assert.equal(renderShellHeaderRule(plainTheme, 12), "─".repeat(12), "the rule spans the full width");
assert.equal(renderShellHeaderRule(plainTheme, 0), "");
assert.equal(renderShellHeaderRule(plainTheme, -3), "", "negative widths clamp to an empty rule");
});
33 changes: 30 additions & 3 deletions tests/shell-sidebar-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function rail(f: ReturnType<typeof fixture>): ScrollView {
assert.equal(node.type, "hstack");
return node.entries[1].component;
}
type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number; grow: number; shrink: number; minSize: number }[] };
type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number | string; grow: number; shrink: number; minSize: number }[] };
// Finds the [left, scroll] hstack regardless of whether it is returned
// directly (no active header) or nested one level under the header vstack.
function hstackOf(f: ReturnType<typeof fixture>): HstackNode {
Expand Down Expand Up @@ -130,7 +130,7 @@ test("wheel scrolls the rail and is consumed at both boundaries and blank space"
f.host.terminal.columns = 160;
const restored = rail(f);
assert.deepEqual(f.bottom.render(80), []);
const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number; grow: number; shrink: number; minSize: number }[] };
const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number | string; grow: number; shrink: number; minSize: number }[] };
assert.equal(layout.gap, 3);
assert.deepEqual(layout.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
{ basis: 0, grow: 1, shrink: 1, minSize: 1 },
Expand Down Expand Up @@ -586,8 +586,10 @@ test("an active header wraps the hstack in a vstack and removes the banner from
assert.equal(node.type, "vstack");
assert.equal(node.gap, 0);
assert.equal(node.align, "stretch");
// basis "auto": pi-tui measures the header leaf from its rendered lines
// (one line: the status bar; with the rule row: two) instead of hard-pinning the row.
assert.deepEqual(node.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
{ basis: 1, grow: 0, shrink: 0, minSize: 1 },
{ basis: "auto", grow: 0, shrink: 0, minSize: 1 },
{ basis: 0, grow: 1, shrink: 1, minSize: 1 },
]);
const header = node.entries[0].component as { render(width: number): string[] };
Expand All @@ -608,6 +610,31 @@ test("an active header wraps the hstack in a vstack and removes the banner from
assert.notEqual(rail[1]?.trim(), "", "the first card starts on the second row");
});

test("the header leaf carries the rule row: a two-line header renders both lines at full width", (t) => {
const f = fixture();
sidebarHeader(f.tui, {
render: (width: number) => [`HEADER ${width}`, `RULE ${width}`],
invalidate() {},
});
t.after(installSidebar(f.tui, theme));

const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
assert.equal(node.type, "vstack", "a two-line header still wraps the hstack in a vstack");
const header = node.entries[0]!.component as { render(width: number): string[] };
assert.deepEqual(header.render(0), ["HEADER 138", "RULE 138"], "both the status line and the rule row reach the layout, rendered at the terminal width minus the rail's right inset");
});

test("below the sidebar breakpoint a rule-like header paints no rule row", (t) => {
const f = fixture("fullscreen", 139);
sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`, `RULE ${width}`], invalidate() {} });
t.after(installSidebar(f.tui, theme));

// Below SIDEBAR_BREAKPOINT prepare() declines, so the replacement hands
// back the native root's own layout node: neither the header wrapper nor
// a rule row ever paints.
assert.deepEqual(f.root[NODE](), f.original(), "below the breakpoint the rule is removed with the whole sidebar");
});

test("without a registered header the rail keeps the banner and the plain hstack", (t) => {
const f = fixture();
t.after(installSidebar(f.tui, theme));
Expand Down