diff options
| author | user <user@clank> | 2026-08-11 00:08:58 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:35 +0200 |
| commit | 129749f0e62526831797ab2ea318a75dd0ce12b0 (patch) | |
| tree | 9cc37969b3cc702e93a4cd2e9b1a614759b0a320 | |
| parent | slop-tool-boxes: stop full-redraw flicker on tall, top-clipped active boxes (diff) | |
| download | sl0ppi-129749f0e62526831797ab2ea318a75dd0ce12b0.tar.gz sl0ppi-129749f0e62526831797ab2ea318a75dd0ce12b0.tar.xz sl0ppi-129749f0e62526831797ab2ea318a75dd0ce12b0.zip | |
chrome: drop side rails across boxes (zellij draws the pane border)
We run the agent inside a zellij fork that already draws a border around
the pane, so per-box verticals were redundant chrome. Body lines are now
indented into the box interior instead of railed, and the top/bottom
rules use straight end caps (a corner would curve toward a vertical that
is no longer there). Each file keeps the original glyphs documented so
the rounded box can be restored by flipping the cap constants back.
Backported from the installed set in ~/.pi/agent/extensions, which had
drifted ahead of this repo.
| -rw-r--r-- | pi-ext/last-prompt.ts | 48 | ||||
| -rw-r--r-- | pi-ext/sl0p-footer.ts | 29 | ||||
| -rw-r--r-- | pi-ext/slop-tool-boxes.ts | 100 | ||||
| -rw-r--r-- | pi-ext/user-prompt-frame.ts | 22 |
4 files changed, 122 insertions, 77 deletions
diff --git a/pi-ext/last-prompt.ts b/pi-ext/last-prompt.ts index 10fd264..14c1330 100644 --- a/pi-ext/last-prompt.ts +++ b/pi-ext/last-prompt.ts @@ -33,6 +33,14 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +// Straight end caps for the top/bottom rules: with no side rails there's no +// vertical for a corner to turn into. Set to ╭╮╰╯ to restore the rounded box. +const H = "─"; +const TL = H; +const TR = H; +const BL = H; +const BR = H; + // Animation cadence while a run is active. 10fps gives a smooth spinner + a // live sub-second timer. Only ever runs during active runs (see file header). const FRAME_MS = 100; @@ -108,9 +116,9 @@ export default function (pi: ExtensionAPI) { } function buildTopBorder(width: number, theme: any, border: (s: string) => string): string { - const h = "─"; - const tl = border("╭"); - const tr = border("╮"); + const h = H; + const tl = border(TL); + const tr = border(TR); const dash = (n: number) => border(h.repeat(Math.max(0, n))); const innerWidth = Math.max(0, width - 2); @@ -153,9 +161,9 @@ export default function (pi: ExtensionAPI) { } function buildBottomBorder(width: number, theme: any, border: (s: string) => string): string { - const h = "─"; - const bl = border("╰"); - const br = border("╯"); + const h = H; + const bl = border(BL); + const br = border(BR); const innerWidth = Math.max(0, width - 2); return `${bl}${border(h.repeat(innerWidth))}${br}`; } @@ -178,17 +186,21 @@ export default function (pi: ExtensionAPI) { const border: (s: string) => string = isRunning ? ((rgb) => (s: string) => fgRGB(rgb, s))(pulseRGB((tick % PULSE_PERIOD) / PULSE_PERIOD)) : (s: string) => theme.fg("accent", s); - const v = border("│"); const topLine = buildTopBorder(width, theme, border); // Prompt text with accent marker — wrap up to MAX_LINES const MAX_LINES = 3; const promptText = lastPrompt!.replace(/\n/g, " ↵ ").replace(/\s+/g, " ").trim(); - const firstPrefix = v + " " + border("❯") + " "; - const contPrefix = v + " "; - const suffix = " " + v; - const available = width - visibleWidth(firstPrefix) - visibleWidth(suffix); + // No side rails — the zellij pane already borders us, so the body is + // just indented to sit under the box interior. The leading blank keeps + // the text in the exact column it occupied when a rail was drawn there, + // and RIGHT_RESERVE keeps the old wrap width (and a right margin) even + // though we no longer draw or pad to a right rail. + const RIGHT_RESERVE = 2; + const firstPrefix = " " + " " + border("❯") + " "; + const contPrefix = " "; + const available = width - visibleWidth(firstPrefix) - RIGHT_RESERVE; const wrapped = wrapTextWithAnsi(promptText, Math.max(1, available)); const lineCount = Math.min(wrapped.length, MAX_LINES); @@ -200,8 +212,7 @@ export default function (pi: ExtensionAPI) { const remaining = wrapped.slice(i).join(" "); text = truncateToWidth(remaining, available, "…"); } - const padLen = Math.max(0, available - visibleWidth(text)); - promptLines.push(`${prefix}${theme.bold(text)}${" ".repeat(padLen)}${suffix}`); + promptLines.push(`${prefix}${theme.bold(text)}`); } const bottomLine = buildBottomBorder(width, theme, border); @@ -222,12 +233,17 @@ export default function (pi: ExtensionAPI) { } }); - // Hide pi's built-in "Working …" status indicator — the last sl0peration box - // already shows a live spinner + timer. Empty frames hides it entirely. + // Hide pi's built-in "Working …" status row — the last sl0peration box already + // shows a live spinner + timer, so the second indicator is pure noise. + // setWorkingVisible(false) drops the whole row (spinner AND message); the empty + // frames call is a fallback for older pi builds that only knew the indicator. // (pi resets this on runtime reset, so we re-apply on session_start too.) function hideWorkingIndicator(ctx: any) { try { - if (ctx?.hasUI && typeof ctx.ui?.setWorkingIndicator === "function") { + if (!ctx?.hasUI) return; + if (typeof ctx.ui?.setWorkingVisible === "function") { + ctx.ui.setWorkingVisible(false); + } else if (typeof ctx.ui?.setWorkingIndicator === "function") { ctx.ui.setWorkingIndicator({ frames: [] }); } } catch { diff --git a/pi-ext/sl0p-footer.ts b/pi-ext/sl0p-footer.ts index 075edeb..070d5a7 100644 --- a/pi-ext/sl0p-footer.ts +++ b/pi-ext/sl0p-footer.ts @@ -2,14 +2,17 @@ * sl0p Footer — dense, single-line replacement for pi's built-in footer. * * pi's native footer is 2–3 dim lines (pwd / stats / extension-status). This - * replaces it entirely (ctx.ui.setFooter) with ONE dense line, wrapped in side - * rails + a closing bottom border so it looks ATTACHED to the input box above - * (input-frame.ts ends in a ├──┤ divider instead of a closing corner): + * replaces it entirely (ctx.ui.setFooter) with ONE dense line plus a closing + * bottom border, so it looks ATTACHED to the input box above (input-frame.ts + * ends in a ├──┤ divider instead of a closing corner): * * ├─────────────────────────────────────┤ ← input box divider - * │ ↑12k ↓3.4k 38%/200k ~/dev/pi-ext (main) glm-4.6 high │ + * ↑12k ↓3.4k 38%/200k ~/dev/pi-ext (main) glm-4.6 high * ╰─────────────────────────────────────╯ * + * No side rails: input-frame.ts dropped them (the zellij pane borders itself), + * and this line has to match it or the seam looks broken. + * * The frame uses `borderMuted` (matches the input box at rest; the input box * border shifts with thinking/bash mode, so the seam only diverges in color * there — intentionally not synced, to keep the two components decoupled). @@ -121,9 +124,9 @@ function renderFooter(width: number, ctx: any, theme: any, footerData: any): str const left = [tok.join(" "), ctxSeg, pathSeg].join(" "); - // Compose the dense line at the INNER width (inside the rails), metrics left / - // model right; truncate the left (path first) if tight. - const inner = Math.max(1, width - 4); // 2 rails + 1 space of padding each side + // Compose the dense line at the INNER width, metrics left / model right; + // truncate the left (path first) if tight. + const inner = Math.max(1, width - 4); // 2-col indent + 2-col right margin const lw = visibleWidth(left); const rw = visibleWidth(right); let content: string; @@ -136,17 +139,17 @@ function renderFooter(width: number, ctx: any, theme: any, footerData: any): str content = truncateToWidth(leftT + " ".repeat(pad) + right, inner); } - // Wrap in side rails + a closing bottom border. Color it with the SAME - // thinking-level border color the input box uses, so the seam under the + // Indent to the box interior + close with the bottom border. Color the border + // with the SAME thinking-level color the input box uses, so the seam under the // divider matches (falls back to borderMuted if the theme lacks the helper). const border: (s: string) => string = typeof (theme as any).getThinkingBorderColor === "function" ? (theme as any).getThinkingBorderColor(thinkLevel || "off") : (s: string) => theme.fg("borderMuted", s); - const cpad = Math.max(0, inner - visibleWidth(content)); - const railed = `${border("│")} ${content}${" ".repeat(cpad)} ${border("│")}`; - const bottom = border("╰" + "─".repeat(Math.max(0, width - 2)) + "╯"); - return [railed, bottom]; + // Straight end caps to match the rest of the chrome (no side rails → nothing + // for a corner to turn into). Use ╰ / ╯ here to restore the rounded box. + const bottom = border("─".repeat(Math.max(0, width))); + return [` ${content}`, bottom]; } export default function (pi: ExtensionAPI) { diff --git a/pi-ext/slop-tool-boxes.ts b/pi-ext/slop-tool-boxes.ts index 2a3bd94..1a7d532 100644 --- a/pi-ext/slop-tool-boxes.ts +++ b/pi-ext/slop-tool-boxes.ts @@ -3,9 +3,12 @@ * * Reframes the built-in tool rows (bash, read, edit, write, grep, find, ls) * in the same rounded-border "sl0peration" style used by the last-prompt - * topbar widget — a labelled top border, side rails, and a rounded bottom. While - * a tool executes the top/bottom borders pulse and the side rails are gray - * dotted; when it finalizes the rails solidify to the status color. + * topbar widget — a labelled top border and a rounded bottom, with the body + * simply indented to sit under the box interior. While a tool executes the + * top/bottom borders pulse; when it finalizes they solidify to the status color. + * + * There are no side rails: we run the agent inside a zellij fork that draws its + * own border around the pane, so per-box verticals were redundant chrome. * * It does NOT reimplement tool behavior or output: it grabs each built-in * tool *definition* (execute + native renderCall/renderResult, so syntax @@ -51,12 +54,14 @@ type ToolDef = { }; const H = "─"; -const V = "│"; -const DOTTED_RAIL = "┊"; // gray dotted side rail while a tool is in-flight -const TL = "╭"; -const TR = "╮"; -const BL = "╰"; -const BR = "╯"; +// End caps for the top/bottom rules. Now that there are no side rails, a corner +// glyph (rounded OR square) would curve/turn toward a vertical that isn't there, +// so the caps are just more horizontal — the rules read as straight rails. +// Set these back to ╭╮╰╯ to restore the rounded box. +const TL = H; +const TR = H; +const BL = H; +const BR = H; type Status = "pending" | "ok" | "error"; type Paint = (s: string) => string; @@ -82,7 +87,7 @@ function borderColorFor(status: Status): string { // taller-than-screen streaming box is exactly what flickered. A silent tool // just holds a static active color until it streams output or finishes. // -// 3. Only the horizontal top/bottom borders pulse. The body side rails are a +// 3. Only the horizontal top/bottom borders pulse. The body lines are a // STATIC color (gray dotted while in-flight, solid status color once // settled), so a repaint never has to recolor body lines. const PULSE: Array<[number, number, number]> = [ @@ -330,7 +335,7 @@ const RESULT_PREVIEW: Record< }, }; -// Top border: ╭─ ✓ bash ───────────────╮ (label carries its own colors) +// Top border: ── ✓ bash ──────────────── (label carries its own colors) function frameTop(width: number, label: string, paint: Paint): string { const innerWidth = Math.max(0, width - 2); const leftLen = innerWidth > 2 ? 1 : 0; @@ -365,18 +370,42 @@ function frameBottom(width: number, paint: Paint, label?: string): string { return paint(BL + H.repeat(innerWidth) + BR); } -// Wrap inner lines with side rails. `rail` is the ALREADY-PAINTED rail glyph -// (gray dotted while in-flight, solid status-colored when settled). Crucially the -// rail is a STATIC string for the whole render pass — it does not carry the -// pulse — so streaming output never recolors existing body lines and can't cause -// a full-box repaint. Only the horizontal top/bottom borders pulse. -function frameBody(lines: string[], width: number, rail: string): string[] { - const innerWidth = Math.max(0, width - 4); // 2 rails + 2 padding spaces +// Per-tool body tint. With the side rails gone, a faint full-width background +// band is what gives each box its identity at a glance — you can tell a bash run +// from a grep from an edit without reading the header. Keep these VERY dark: they +// sit under real syntax highlighting, so anything with punch destroys contrast. +// Drop a tool from this map (or set it to undefined) to leave that body untinted. +const TOOL_BG: Record<string, string> = { + bash: "#101a22", // cool slate — shell + read: "#0f1a15", // green + edit: "#1a1526", // violet + write: "#20141c", // magenta + grep: "#221a0f", // amber + find: "#0f1c20", // teal + ls: "#171717", // neutral +}; + +// Indent inner lines to sit under the box interior. The side rails were dropped +// (the zellij pane already draws a border around the whole pane, so per-box +// verticals were redundant chrome) — only the labelled top/bottom horizontals +// delimit a box now. Layout width is still width-4, so content wraps exactly as +// before and keeps a 2-col breathing margin on the right. +// +// With a `bg` the line is padded to the full pane width so the band is a solid +// block (that padding is the one place trailing whitespace is unavoidable — it +// IS the tint). fillBg re-asserts the bg after any reset inside the content, so +// edit's own add/remove diff bands nest correctly: the diff band paints, ends, +// and the tool tint resumes underneath it. +// +// The tint is STATIC per tool, so this keeps the anti-flicker property: body +// lines stay byte-identical across pulse frames and only the horizontal +// top/bottom borders repaint while a tool streams. (NOTES.md) +function frameBody(lines: string[], width: number, bg?: string): string[] { + const innerWidth = Math.max(0, width - 4); return lines.map((raw) => { let line = raw; if (visibleWidth(line) > innerWidth) line = truncateToWidth(line, innerWidth, "…"); - const pad = Math.max(0, innerWidth - visibleWidth(line)); - return `${rail} ${line}${" ".repeat(pad)} ${rail}`; + return bg ? fillBg(bg, ` ${line}`, width) : ` ${line}`; }); } @@ -516,15 +545,15 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { statusIcon(status, theme) + " " + theme.fg("toolTitle", theme.bold(displayName)); const label = st.summaryText ? base + " " + theme.fg("accent", st.summaryText) : base; const inner = innerLines(width); - // IMPORTANT (anti-flicker): the side rails + body use a STATIC status - // color; only the horizontal top/bottom borders pulse. Pulsing the - // rails recolors every body line each frame, which forces a full-box - // repaint while a tool streams output (edit/write) and reads as violent - // flicker. Keeping rails static means streaming only redraws the new - // content line, and the pulse only touches 1-2 border lines. (NOTES.md) + // IMPORTANT (anti-flicker): body lines carry NO border color; only the + // horizontal top/bottom borders pulse. Anything that recolors every body + // line each frame forces a full-box repaint while a tool streams output + // (edit/write) and reads as violent flicker. With uncolored bodies, + // streaming only redraws the new content line and the pulse only touches + // 1-2 border lines. (NOTES.md) const rt = RUNTIME_TOOLS.has(displayName) ? runtimeLabel(st, theme) : undefined; - const build = (border: Paint, rail: string): string[] => { - const out = [frameTop(width, label, border), ...frameBody(inner, width, rail)]; + const build = (border: Paint): string[] => { + const out = [frameTop(width, label, border), ...frameBody(inner, width, TOOL_BG[displayName])]; // While the result slot hasn't started rendering yet (still-running // tool), close the box here so the frame is never bottomless. if (!st.resultActive) out.push(frameBottom(width, border, rt)); @@ -532,10 +561,9 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { }; if (activeNow) { cw = -1; // settled cache must rebuild once the row finishes - const rail = theme.fg("muted", DOTTED_RAIL); // Pre-output (no result slot yet): the whole box is short + visible, // so keep the live pulse (and ticking runtime in the bottom border). - if (!st.resultActive) return build(pulsePaint(), rail); + if (!st.resultActive) return build(pulsePaint()); // Result is streaming below — this slot owns the TOP border, which may // be scrolled off-screen. Freeze the pulse color while our content + // width are unchanged so the top border is byte-identical between the @@ -545,14 +573,14 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { if (width !== st.callActiveW || inner !== st.callActiveInner) { st.callActiveW = width; st.callActiveInner = inner; - st.callActiveLines = build(pulsePaint(), rail); + st.callActiveLines = build(pulsePaint()); } return st.callActiveLines!; } if (width === cw) return cl; - // settled: solid borders + rails in the status color + // settled: solid borders in the status color const solid = solidPaint(theme, borderColorFor(status)); - cl = build(solid, theme.fg(borderColorFor(status), V)); + cl = build(solid); cw = width; return cl; }, @@ -618,13 +646,9 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { render(width: number): string[] { const inner = innerLines(width); const activeNow = isActiveRow(st); - // in-flight: gray dotted rails; settled: solid status-colored rails. - const railStr = activeNow - ? theme.fg("muted", DOTTED_RAIL) - : theme.fg(borderColorFor(st.status), V); const rt = RUNTIME_TOOLS.has(displayName) ? runtimeLabel(st, theme) : undefined; const build = (border: Paint): string[] => [ - ...frameBody(inner, width, railStr), + ...frameBody(inner, width, TOOL_BG[displayName]), frameBottom(width, border, rt), ]; if (activeNow) { diff --git a/pi-ext/user-prompt-frame.ts b/pi-ext/user-prompt-frame.ts index 7d2ee50..b1e40f6 100644 --- a/pi-ext/user-prompt-frame.ts +++ b/pi-ext/user-prompt-frame.ts @@ -35,11 +35,12 @@ const LABEL = ""; // ──────────────────────────────────────────────────────────────────────────── const H = "─"; -const V = "│"; -const TL = "╭"; -const TR = "╮"; -const BL = "╰"; -const BR = "╯"; +// Straight end caps: with no side rails there's no vertical for a corner to turn +// into. Set back to ╭╮╰╯ to restore the rounded box. +const TL = H; +const TR = H; +const BL = H; +const BR = H; // Global-symbol keyed so reloads/multiple loads never double-wrap. const ORIG = Symbol.for("sl0p.userPromptFrame.origRender"); @@ -72,18 +73,19 @@ function frameTop(width: number): string { return fg(FRAME_HEX, TL + H.repeat(leftLen)) + content + fg(FRAME_HEX, H.repeat(rightLen) + TR); } +// No side rails: the zellij pane draws its own border, so per-box verticals were +// redundant. Body lines are just indented into the box interior — same content +// column as before, minus the rail glyph and the trailing pad/rail. function frame(lines: string[], width: number): string[] { const innerWidth = Math.max(0, width - 2); - const bodyInner = Math.max(0, width - 4); // rails (2) + 1 space padding per side - const v = fg(FRAME_HEX, V); + const bodyInner = Math.max(0, width - 4); // 2-col indent + 2-col right margin const body = lines.map((raw) => { let s = raw; if (visibleWidth(s) > bodyInner) s = truncateToWidth(s, bodyInner, "…"); - const pad = Math.max(0, bodyInner - visibleWidth(s)); - return `${v} ${s}${" ".repeat(pad)} ${v}`; + return ` ${s}`; }); const bottom = fg(FRAME_HEX, BL + H.repeat(innerWidth) + BR); - return [frameTop(width), ...body, bottom]; + return [ ...body, bottom]; } export default function (pi: ExtensionAPI) { |
