From f39f3049a6d6c79d21df35c9fa924f52fc7cfc81 Mon Sep 17 00:00:00 2001
From: Carolina <26188349+carolitascl@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:18:55 -0300
Subject: [PATCH] feat(shell): paint a rule row under the fullscreen status
line
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add renderShellHeaderRule — one full-width "─" line in the editor
frame's border role — and render it as the fullscreen header rail's
second line so the status bar and the prompt read as one panel. The
header vstack entry switches to basis "auto" so pi-tui measures the
leaf's rendered lines instead of pinning a single row, and the usage
click affordance stays bound to the status row only.
Responsive by construction: the rule renders only while the fullscreen
sidebar is active; below the sidebar breakpoint (or outside
fullscreen) the header rail never paints and the rule is removed with
it. The compact bottom bar is untouched.
Tests: renderShellHeaderRule unit cases (border role, full width,
clamps), two-line header passthrough at full terminal width, and
below-breakpoint removal of the rule row.
---
extensions/gentle-shell.ts | 5 +++--
lib/shell-bar.ts | 13 ++++++++++++
lib/shell-sidebar-layout.ts | 8 +++++---
tests/shell-bar.test.ts | 8 ++++++++
tests/shell-sidebar-layout.test.ts | 33 +++++++++++++++++++++++++++---
5 files changed, 59 insertions(+), 8 deletions(-)
diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts
index b4ad6e28c..9dcd23913 100644
--- a/extensions/gentle-shell.ts
+++ b/extensions/gentle-shell.ts
@@ -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";
@@ -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);
diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts
index 1df4e5252..ce20fd29b 100644
--- a/lib/shell-bar.ts
+++ b/lib/shell-bar.ts
@@ -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;
diff --git a/lib/shell-sidebar-layout.ts b/lib/shell-sidebar-layout.ts
index 6f87cd66d..bc37d4d7d 100644
--- a/lib/shell-sidebar-layout.ts
+++ b/lib/shell-sidebar-layout.ts
@@ -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)
@@ -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]();
diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts
index 55ca2d19d..d7c4f21f7 100644
--- a/tests/shell-bar.test.ts
+++ b/tests/shell-bar.test.ts
@@ -9,6 +9,7 @@ import {
renderGauge,
renderShellBar,
renderShellHeaderBar,
+ renderShellHeaderRule,
renderShellSidebarBar,
shellEnabled,
type ShellBarModel,
@@ -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), "────", "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");
+});
diff --git a/tests/shell-sidebar-layout.test.ts b/tests/shell-sidebar-layout.test.ts
index f00d42a35..3c7b4ceac 100644
--- a/tests/shell-sidebar-layout.test.ts
+++ b/tests/shell-sidebar-layout.test.ts
@@ -23,7 +23,7 @@ function rail(f: ReturnType): 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): HstackNode {
@@ -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 },
@@ -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[] };
@@ -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));