diff --git a/pi-slate/README.md b/pi-slate/README.md index b607625..98e77cd 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -125,7 +125,7 @@ These work when `editor` is selected. No terminal configuration. | Session dashboard | `/slate session [mcp\|skills\|commands\|tasks\|image]` | Inspect facts, expand/collapse resources, insert commands, or manage the image shelf. | | Message length | `/slate message-length [default\|all\|]` | How many chat messages stay on screen. `default` is 100. | | Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. | -| Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. Not footer-slot ownership. | +| Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. When the dashboard is hidden, thinking follows context/MCP on the top edge and the model stays below. Not footer-slot ownership. | | Header logo | `/logo [original\|minimal]` | Toggle the stock brand mark and the white monochrome mark, or set a style explicitly. | | Theme | `/theme` (or `/slate theme`) | Opens the Light/Dark picker: Latte and Frappé under Light, Macchiato, Mocha, Tokyo Night, and Black Metal under Dark. 15 palettes per Catppuccin flavor, live preview, no arguments. | | Bugs | `/slate bug [file\|open]` | Copy a bug report, or open the npm package page. | diff --git a/pi-slate/extensions/pi-slate/composer.ts b/pi-slate/extensions/pi-slate/composer.ts index 6b972be..1f823b9 100644 --- a/pi-slate/extensions/pi-slate/composer.ts +++ b/pi-slate/extensions/pi-slate/composer.ts @@ -462,11 +462,13 @@ export class ComposerEditor extends CustomEditor { } : undefined; const pidLabel = src.showPid === true ? src.theme.fg("dim", `PID-${AGENT_PID}`) : ""; - const resources = src.context - ? src.context.resources.split(" · ") - .map((part) => src.theme.fg("dim", part)) - .join(metadataSeparator(src.theme, "dim")) - : ""; + const resourceParts = src.context ? src.context.resources.split(" · ") : []; + if (src.dashboardVisible === false && src.context && src.footer === "standard" && src.thinking) { + resourceParts.push(src.thinking); + } + const resources = resourceParts + .map((part) => src.theme.fg("dim", part)) + .join(metadataSeparator(src.theme, "dim")); return composerStatusContextEdge( resources, width, @@ -497,7 +499,6 @@ export class ComposerEditor extends CustomEditor { const rightParts: string[] = []; if (src.footer === "standard" && width >= 66) { rightParts.push(modelStatusLabel(src.model, src.modelDisplay)); - if (width >= 80 && src.thinking) rightParts.push(src.thinking); } const right = rightParts.length ? ` ${rightParts.map((part) => src.theme.fg("muted", part)).join(metadataSeparator(src.theme))} ` diff --git a/pi-slate/extensions/pi-slate/diff-renderer.ts b/pi-slate/extensions/pi-slate/diff-renderer.ts index fa4c0df..9e7a077 100644 --- a/pi-slate/extensions/pi-slate/diff-renderer.ts +++ b/pi-slate/extensions/pi-slate/diff-renderer.ts @@ -142,7 +142,10 @@ export class DiffView implements Component { } private fit(text: string, width: number, bg = this.config.colors.contextBg): string { - const clipped = truncateToWidth(text, width, "…"); + // Truncation resets SGR before the ellipsis; repaint it rather than exposing + // the host tool/terminal background at the edge of a clipped code cell. + const ellipsis = ansiColor(bg, true) + ansiColor(this.config.colors.fg) + "…"; + const clipped = truncateToWidth(text, width, ellipsis); return ansiColor(bg, true) + clipped + RESET + ansiColor(bg, true) + " ".repeat(Math.max(0, width - visibleWidth(clipped))) + RESET; } @@ -204,7 +207,8 @@ export class DiffView implements Component { if (split) { const leftWidth = Math.floor((width - 3) / 2); const rightWidth = width - leftWidth - 3; - const border = ansiColor(this.config.colors.borderFg) + " │ " + RESET; + const border = ansiColor(this.config.colors.contextBg, true) + + ansiColor(this.config.colors.borderFg) + " │ " + RESET; output.push(this.fit(ansiColor(this.config.colors.lineNumberFg) + "Before", leftWidth) + border + this.fit(ansiColor(this.config.colors.lineNumberFg) + "After", rightWidth)); const pairs = pairRows(this.rows); diff --git a/pi-slate/tests/composer.test.ts b/pi-slate/tests/composer.test.ts index f061271..ef2b74c 100644 --- a/pi-slate/tests/composer.test.ts +++ b/pi-slate/tests/composer.test.ts @@ -170,7 +170,7 @@ test("composer metadata separators match the surrounding metadata tone", () => { assert.doesNotMatch(labels.right, /\[borderMuted\]/); }); -test("focused composer bottom edge drops ctx and uses readable separators", () => { +test("focused composer puts thinking after MCP on the top edge, leaving the model below", () => { const editor = new ComposerEditor( { terminal: { rows: 24, columns: 100 }, requestRender() {} } as TUI, { borderColor: (text) => text } as EditorTheme, @@ -187,14 +187,41 @@ test("focused composer bottom edge drops ctx and uses readable separators", () = }), ); const lines = editor.render(100); - assert.match(lines[0]!, /\[dim\]ctx 10%\[dim\] · \[dim\]2 mcp/); + assert.match(lines[0]!, /\[dim\]ctx 10%\[dim\] · \[dim\]2 mcp\[dim\] · \[dim\]medium/); const bottom = lines.at(-1)!; - assert.doesNotMatch(bottom, /ctx 10%/); - assert.match(bottom, /\[muted\] · /); + assert.doesNotMatch(bottom, /ctx 10%|medium| · /); assert.match(bottom, /sample/); assert.doesNotMatch(bottom, /borderMuted/); }); +test("top-edge thinking respects minimal metadata, narrow widths, and missing levels", () => { + for (const footer of ["standard", "minimal"] as const) { + for (const thinking of [undefined, "medium"]) { + for (const width of [12, 20, 32, 40, 65, 79, 80, 100]) { + const editor = new ComposerEditor( + { terminal: { rows: 24, columns: width }, requestRender() {} } as TUI, + { borderColor: (text) => text } as EditorTheme, + new KeybindingsManager(), + () => ({ + project: "slate", branch: null, model: { id: "sample" }, thinking, footer, theme, + dashboardVisible: false, + context: { tokens: "10%/100k tokens", resources: "ctx 10% · 1 mcp" }, + }), + ); + const lines = editor.render(width).map(stripVTControlCharacters); + assert.ok(lines.every(line => visibleWidth(line) <= width)); + if (footer === "standard" && thinking) { + if (width >= 32) assert.match(lines[0]!, /ctx 10% · 1 mcp · medium/); + } else { + assert.doesNotMatch(lines[0]!, /medium/); + } + assert.doesNotMatch(lines[0]!, /undefined/); + assert.doesNotMatch(lines.at(-1)!, /medium/); + } + } + } +}); + const labelTheme = { fg: (name: string, text: string) => `[${name}]${text}`, italic: (text: string) => `{i}${text}`, diff --git a/pi-slate/tests/diff.test.ts b/pi-slate/tests/diff.test.ts index 460b556..8b298a7 100644 --- a/pi-slate/tests/diff.test.ts +++ b/pi-slate/tests/diff.test.ts @@ -94,6 +94,36 @@ test("Shiki colors are composited with diff and character backgrounds", async (t for (const token of added.tokens ?? []) if (token.color) assert.ok(output.includes(ansiColor(token.color))); }); +test("diff paints every visible cell, including split dividers and clipped ellipses", async (t) => { + const diff = view(t, "unchanged\n\n" + "old ".repeat(80) + "\nremoved\n", "unchanged\n\n" + "new ".repeat(80) + "\n"); + const assertPainted = () => { + for (const width of [1, 2, 20, 80, 100, 101, 120]) { + for (const line of diff.render(width)) { + let background: string | undefined; + for (const match of line.matchAll(/\x1b\[([\d;]*)m|([^\x1b]+)/g)) { + if (match[1] !== undefined) { + const codes = match[1].split(";").map(Number); + for (let i = 0; i < codes.length; i++) { + if (codes[i] === 0 || codes[i] === 49) background = undefined; + if (codes[i] === 38 || codes[i] === 48) { + if (codes[i + 1] === 2) { + if (codes[i] === 48) background = codes.slice(i + 2, i + 5).join(","); + i += 4; + } + } + } + } else { + assert.ok(background, `unpainted ${JSON.stringify(match[2])} at width ${width}`); + } + } + } + } + }; + assertPainted(); + await diff.ready; + assertPainted(); +}); + test("edit is responsive to actual pane width; write is always unified", async (t) => { const edit = view(t, "old\n", "new\n"); const write = view(t, "old\n", "new\n", { kind: "write" }); diff --git a/pi-slate/tests/quiet-workbench.test.ts b/pi-slate/tests/quiet-workbench.test.ts index d3b81dd..49e6c58 100644 --- a/pi-slate/tests/quiet-workbench.test.ts +++ b/pi-slate/tests/quiet-workbench.test.ts @@ -148,5 +148,8 @@ test("quiet editor avoids duplicate identity/model and restores compact facts wi } } assert.equal(editor.getText(), "Preserve this draft"); - assert.match(editor.render(100).map(stripVTControlCharacters).join("\n"), /sample-model · medium/); + const focused = editor.render(100).map(stripVTControlCharacters); + assert.match(focused[0]!, /ctx 11% · 2 mcp · medium/); + assert.match(focused.at(-1)!, /sample-model/); + assert.doesNotMatch(focused.at(-1)!, /medium/); });