diff --git a/pi-slate/README.md b/pi-slate/README.md index ca64aec..b607625 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -126,6 +126,7 @@ These work when `editor` is selected. No terminal configuration. | 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. | +| 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. | @@ -137,7 +138,7 @@ Changes are saved and require `/reload`; appearance settings stay live. A fresh | Surface | Owns | | --- | --- | -| `header` | Compact workspace identity and update hairline | +| `header` | Compact workspace identity | | `footer` | Zero-row footer (hides Pi's native footer) | | `editor` | Composer, keys, paste/images, working/thinking status | | `sidebar` | Session dashboard, resources, tasks and images | diff --git a/pi-slate/extensions/pi-slate/composer.ts b/pi-slate/extensions/pi-slate/composer.ts index af482d3..6b972be 100644 --- a/pi-slate/extensions/pi-slate/composer.ts +++ b/pi-slate/extensions/pi-slate/composer.ts @@ -21,6 +21,11 @@ export function chromePaint(theme: Theme): (text: string) => string { return (text) => theme.fg("border", text); } +/** Separators inherit their line's metadata tone so they never stand out from nearby text. */ +function metadataSeparator(theme: Theme, tone: "muted" | "dim" = "muted"): string { + return theme.fg(tone, " · "); +} + const ESCAPE_SEQUENCE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|_[^\x07]*(?:\x07|$)|\][^\x07]*(?:\x07|\x1b\\|$))/y; const REVERSE_ON = "\x1b[7m"; const REVERSE_OFF = "\x1b[27m"; @@ -216,7 +221,7 @@ export function composerLabels( if (visible.showTokens && input.tokens) parts.push(theme.fg("muted", input.tokens)); if (visible.showModel) parts.push(theme.fg("muted", input.model)); if (visible.showThinking && input.thinking) parts.push(theme.fg("dim", input.thinking)); - const modelLabelText = parts.length ? ` ${parts.join(theme.fg("borderMuted", " · "))} ` : ""; + const modelLabelText = parts.length ? ` ${parts.join(metadataSeparator(theme))} ` : ""; return { left: projectLabel, right: modelLabelText }; } @@ -268,7 +273,7 @@ export type ComposerSource = { thinking?: string; footer: "standard" | "minimal"; theme: Theme; - context?: { tokens: string; resources: string; summary?: string }; + context?: { tokens: string; resources: string }; working?: boolean; showPid?: boolean; effortBorderColor?: boolean; @@ -457,8 +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")) + : ""; return composerStatusContextEdge( - src.context ? src.theme.fg("dim", src.context.resources) : "", + resources, width, paint, hiddenLineCount, @@ -484,12 +494,14 @@ export class ComposerEditor extends CustomEditor { width, ); if (src.dashboardVisible === false && src.context) { - const rightParts = [src.context.summary ?? src.context.tokens]; + 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 = src.theme.fg("muted", ` ${rightParts.join(src.theme.fg("borderMuted", " · "))} `); + const right = rightParts.length + ? ` ${rightParts.map((part) => src.theme.fg("muted", part)).join(metadataSeparator(src.theme))} ` + : ""; return inscribedBorder(`${more}${left}`, right, width, text => this.borderColor(text), "└", "┘"); } if (src.dashboardVisible) return inscribedBorder(`${more}${left}`, right, width, text => this.borderColor(text), "└", "┘"); diff --git a/pi-slate/extensions/pi-slate/hairline.ts b/pi-slate/extensions/pi-slate/hairline.ts deleted file mode 100644 index 722480d..0000000 --- a/pi-slate/extensions/pi-slate/hairline.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; - -export const HAIRLINE_MIN_DASH = 2; -export const HAIRLINE_GAP = 1; - -const BREAKS = [" · ", ", ", " "]; - -export function hairlineTextWidth(width: number): number { - return Math.max(1, width - 2 * HAIRLINE_MIN_DASH - 2 * HAIRLINE_GAP); -} - -export function wrapHairlineText(text: string, maxWidth: number): string[] { - const trimmed = text.trim(); - if (!trimmed || maxWidth <= 0) return []; - if (visibleWidth(trimmed) <= maxWidth) return [trimmed]; - - const lines: string[] = []; - let rest = trimmed; - while (rest) { - if (visibleWidth(rest) <= maxWidth) { - lines.push(rest); - break; - } - const chunk = takeWrap(rest, maxWidth); - if (!chunk.text) { - lines.push(truncateToWidth(rest, maxWidth, "…")); - break; - } - lines.push(chunk.text); - rest = chunk.rest.trimStart(); - } - return lines; -} - -function takeWrap(text: string, maxWidth: number): { text: string; rest: string } { - for (const sep of BREAKS) { - let best = -1; - let from = 0; - while (from < text.length) { - const idx = text.indexOf(sep, from); - if (idx <= 0) break; - if (visibleWidth(text.slice(0, idx)) > maxWidth) break; - best = idx; - from = idx + sep.length; - } - if (best > 0) { - return { text: text.slice(0, best).trimEnd(), rest: text.slice(best + sep.length) }; - } - } - - const cut = truncateToWidth(text, maxWidth, ""); - if (!cut) return { text: text.slice(0, 1), rest: text.slice(1) }; - return { text: cut, rest: text.slice(cut.length) }; -} - -export function dashCount(textWidth: number, width: number): number { - const remaining = Math.max(0, width - textWidth - 2 * HAIRLINE_GAP); - return Math.floor(remaining / 2); -} - -export function symmetricHairline( - text: string, - width: number, - paint: (dash: string) => string = (value) => value, -): string { - if (width <= 0) return ""; - const maxText = hairlineTextWidth(width); - const body = visibleWidth(text) > maxText ? truncateToWidth(text, maxText, "…") : text; - const dashes = "─".repeat(dashCount(visibleWidth(body), width)); - const gap = " ".repeat(HAIRLINE_GAP); - const core = `${paint(dashes)}${gap}${body}${gap}${paint(dashes)}`; - const slack = Math.max(0, width - visibleWidth(core)); - const left = Math.floor(slack / 2); - return `${" ".repeat(left)}${core}${" ".repeat(slack - left)}`; -} - -export function centeredHairlines( - text: string, - width: number, - paint: (dash: string) => string = (value) => value, -): string[] { - return wrapHairlineText(text, hairlineTextWidth(width)).map((line) => symmetricHairline(line, width, paint)); -} diff --git a/pi-slate/extensions/pi-slate/header.ts b/pi-slate/extensions/pi-slate/header.ts index 8e2cf95..a67ac26 100644 --- a/pi-slate/extensions/pi-slate/header.ts +++ b/pi-slate/extensions/pi-slate/header.ts @@ -3,10 +3,8 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { backgroundAnsi, foregroundAnsi, rgbColor, truncateToWidth, visibleWidth, type Component, type TerminalColorMode, } from "@earendil-works/pi-tui"; -import { hairlineTextWidth, wrapHairlineText, symmetricHairline } from "./hairline.ts"; -import { paintLogo, PI_LOGO, type LogoStyle } from "./layout.ts"; +import { paintLogo, type LogoStyle } from "./layout.ts"; import { sidebarText } from "./sidebar-data.ts"; -import { formatUpdateNotice, type UpdateNotice } from "./updates.ts"; const CORAL = rgbColor(228, 138, 122); const BLUE = rgbColor(79, 142, 179); @@ -14,6 +12,19 @@ const YELLOW = rgbColor(234, 182, 93); const RESET = "\x1b[0m"; const LOGO_CELLS = 4; +type PiMarkColor = "coral" | "blue" | "yellow"; + +// The stock Pi mark is a 4×4 source grid. Each terminal cell carries two +// source squares as a foreground/background half-block pair. +const PI_MARK_COLORS: readonly (readonly (PiMarkColor | null)[])[] = [ + ["coral", "coral", "coral", null], + ["blue", null, "coral", null], + ["blue", "blue", null, "yellow"], + ["blue", null, null, "yellow"], +]; +const PI_MARK_MASK = PI_MARK_COLORS.map((row) => row.map((color) => color !== null)); +const MARK_COLORS: Record = { coral: CORAL, blue: BLUE, yellow: YELLOW }; + export function supportsPiLogo(env: NodeJS.ProcessEnv = process.env): boolean { return env.TERM_PROGRAM !== "Apple_Terminal"; } @@ -22,20 +33,51 @@ function colorMode(theme: Theme): TerminalColorMode { return typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor"; } -/** Stock Pi mark: 4 cells × 2 rows of half-blocks. Brand colors stay fixed. */ +function renderPiMark( + cell: (top: boolean, bottom: boolean, row: number, column: number) => string, +): [string, string] { + const rows: string[] = []; + for (let row = 0; row < PI_MARK_MASK.length; row += 2) { + let line = ""; + for (let column = 0; column < (PI_MARK_MASK[row]?.length ?? 0); column++) { + line += cell( + PI_MARK_MASK[row]?.[column] ?? false, + PI_MARK_MASK[row + 1]?.[column] ?? false, + row, + column, + ); + } + rows.push(line); + } + return [rows[0] ?? "", rows[1] ?? ""]; +} + +/** Stock Pi mark: the brand-colored 4×2 half-block grid. */ export function piLogoLines(theme: Theme): [string, string] { const mode = colorMode(theme); - const fg = (color: typeof CORAL) => foregroundAnsi(color, mode); - return [ - `${fg(CORAL)}${backgroundAnsi(BLUE, mode)}▀${RESET}${fg(CORAL)}▀█${RESET} `, - `${fg(BLUE)}█▀${RESET} ${fg(YELLOW)}█${RESET}`, - ]; + return renderPiMark((_top, _bottom, row, column) => { + const top = PI_MARK_COLORS[row]?.[column] ?? null; + const bottom = PI_MARK_COLORS[row + 1]?.[column] ?? null; + if (!top && !bottom) return " "; + if (top && bottom) { + return top === bottom + ? `${foregroundAnsi(MARK_COLORS[top], mode)}█${RESET}` + : `${foregroundAnsi(MARK_COLORS[top], mode)}${backgroundAnsi(MARK_COLORS[bottom], mode)}▀${RESET}`; + } + const color = top ?? bottom!; + return `${foregroundAnsi(MARK_COLORS[color], mode)}${top ? "▀" : "▄"}${RESET}`; + }); } -/** Classic Pi mark: 8 cells × 4 rows of full blocks, painted white in every theme. */ -export function piMinimalLogoLines(theme: Theme): string[] { +/** Classic Pi mark: the same 4×2 half-block grid, painted white in every theme. */ +export function piMinimalLogoLines(theme: Theme): [string, string] { const truecolor = colorMode(theme) === "truecolor"; - return PI_LOGO.map((line) => paintLogo(line, truecolor)); + return renderPiMark((top, bottom) => { + if (top && bottom) return paintLogo("█", truecolor); + if (top) return paintLogo("▀", truecolor); + if (bottom) return paintLogo("▄", truecolor); + return " "; + }); } export function piWordmark(theme: Theme): string { @@ -43,12 +85,6 @@ export function piWordmark(theme: Theme): string { return `${foregroundAnsi(CORAL, mode)}P${RESET}${foregroundAnsi(YELLOW, mode)}i${RESET}`; } -export function renderUpdateHairlines( - notice: string, width: number, paintDash: (text: string) => string, paintText: (text: string) => string, -): string[] { - return wrapHairlineText(notice, hairlineTextWidth(width)).map(line => symmetricHairline(paintText(line), width, paintDash)); -} - function padLine(text: string, width: number): string { const gap = Math.max(0, width - visibleWidth(text)); return `${text}${" ".repeat(gap)}`; @@ -65,9 +101,9 @@ function indentedLine(text: string, width: number, cells: number): string { export type HeaderIdentity = { version: string; model?: string; thinking?: string }; -/** Official mark + product stack. No hairline without a notice. */ +/** Official mark + product stack. */ export function renderSlateHeader(input: { - width: number; path: string; identity?: HeaderIdentity; notice?: string; + width: number; path: string; identity?: HeaderIdentity; logo?: boolean; logoStyle?: LogoStyle; theme: Theme; }): string[] { const width = Math.max(0, input.width); @@ -81,17 +117,10 @@ export function renderSlateHeader(input: { const showLogo = input.logo ?? supportsPiLogo(); const modelLine = [model, thinking].filter(Boolean).join(" · "); const rows: string[] = []; - if (showLogo && width >= 12 && input.logoStyle !== "original") { - const cells = 8; - const mark = piMinimalLogoLines(input.theme); - rows.push( - logoRow(mark[0]!, input.theme.fg("text", `Pi Agent v${version}`), width, cells), - logoRow(mark[1]!, modelLine ? input.theme.fg("muted", modelLine) : "", width, cells), - logoRow(mark[2]!, input.theme.fg("dim", path), width, cells), - logoRow(mark[3]!, "", width, cells), - ); - } else if (showLogo && width >= 12) { - const [top, bottom] = piLogoLines(input.theme); + if (showLogo && width >= 12) { + const [top, bottom] = input.logoStyle === "original" + ? piLogoLines(input.theme) + : piMinimalLogoLines(input.theme); rows.push( logoRow(top, input.theme.fg("text", `Pi Agent v${version}`), width, LOGO_CELLS), logoRow(bottom, modelLine ? input.theme.fg("muted", modelLine) : "", width, LOGO_CELLS), @@ -103,8 +132,6 @@ export function renderSlateHeader(input: { if (modelLine) rows.push(padLine(truncateToWidth(input.theme.fg("muted", modelLine), width, "…"), width)); if (path) rows.push(padLine(truncateToWidth(input.theme.fg("dim", path), width, "…"), width)); } - if (input.notice) rows.push(...renderUpdateHairlines(input.notice, width, - text => input.theme.fg("border", text), text => input.theme.fg("accent", text))); return rows; } @@ -112,26 +139,23 @@ export class SlateHeader implements Component { private readonly theme: Theme; private readonly getContext: () => ExtensionContext | undefined; private readonly columnWidth: (width: number) => number; - private readonly getNotice: () => UpdateNotice; private readonly getIdentity: () => HeaderIdentity; private readonly getLogoStyle: () => LogoStyle; constructor(theme: Theme, getContext: () => ExtensionContext | undefined, - columnWidth: (width: number) => number, getNotice: () => UpdateNotice, + columnWidth: (width: number) => number, getIdentity: () => HeaderIdentity, getLogoStyle: () => LogoStyle = () => "minimal") { this.theme = theme; this.getContext = getContext; this.columnWidth = columnWidth; - this.getNotice = getNotice; this.getIdentity = getIdentity; + this.getIdentity = getIdentity; this.getLogoStyle = getLogoStyle; } invalidate(): void {} render(width: number): string[] { const ctx = this.getContext(); if (!ctx) return []; - const notice = formatUpdateNotice(this.getNotice()); const identity = this.getIdentity(); return renderSlateHeader({ width: this.columnWidth(width), path: ctx.cwd, identity, theme: ctx.ui.theme ?? this.theme, logoStyle: this.getLogoStyle(), - ...(notice ? { notice } : {}), }); } } diff --git a/pi-slate/extensions/pi-slate/index.ts b/pi-slate/extensions/pi-slate/index.ts index e3a3500..5916c82 100644 --- a/pi-slate/extensions/pi-slate/index.ts +++ b/pi-slate/extensions/pi-slate/index.ts @@ -42,14 +42,14 @@ import { createWordPicker } from "./working-words.ts"; import { advanceWave, isProgressStyle, isWaveActive, PROGRESS_STYLES, setProgressStyle, setWaveActive, type ProgressStyle } from "./working-wave.ts"; import { formatLiveThinking, ThinkingFoldTracker } from "./thinking-fold.ts"; import { - countSkillCommands, - LOGO_STYLES, type LogoStyle, formatFocusedContextResources, formatFocusedContextTokens, - formatPercent, + logoArgumentCompletions, + LOGO_USAGE, modelStatusLabel, parseMessageLength, + parseLogoArgs, parseMessageLengthArg, parseSidebarPercent, parseSidebarWidthArg, @@ -71,7 +71,6 @@ import { type ModelDisplay, } from "./layout.ts"; import { SlateHeader } from "./header.ts"; -import { UpdateWatcher } from "./updates.ts"; import { formatBugReport, issueTemplate, @@ -275,12 +274,10 @@ export default function piSlate(pi: ExtensionAPI): void { }; const tokenRate = new TokenRateTracker(); const thinkingFold = new ThinkingFoldTracker({ onChange: () => syncThinkingStatus() }); - const updates = new UpdateWatcher(); const branch = new GitBranchPoller(pi, (next) => { gitBranch = next; requestRender(); }); let contextEdge = { tokens: formatFocusedContextTokens(null, null, null), - resources: formatFocusedContextResources(null, 0, 0), - summary: "ctx —", + resources: formatFocusedContextResources(null, false, 0), }; let requestRender = (_force = false) => {}; @@ -316,8 +313,6 @@ export default function piSlate(pi: ExtensionAPI): void { const entries = ctx.sessionManager.getBranch(); branchFacts = { leaf, counts: sidebarMessageCounts(entries), usage: sidebarUsage(entries) }; } - const spend = branchFacts.usage.cost; - const skillCount = owns("sidebar") ? resources.skills.filter(skill => skill.loaded).length : countSkillCommands(pi.getCommands()); const mcpCount = resources.mcp.filter(server => server.enabled === true).length; if (owns("sidebar") && !sidebarYielded) { sidebar.setSession({ @@ -331,8 +326,7 @@ export default function piSlate(pi: ExtensionAPI): void { } contextEdge = { tokens: formatFocusedContextTokens(percent, tokenRate.rate(), contextWindow), - resources: formatFocusedContextResources(spend, skillCount, mcpCount), - summary: `ctx ${estimated && percent !== null ? "~" : ""}${formatPercent(percent)}`, + resources: formatFocusedContextResources(percent, estimated, mcpCount), }; }; const refreshResources = (ctx: ExtensionContext): void => { @@ -516,8 +510,6 @@ export default function piSlate(pi: ExtensionAPI): void { }, { placement: "belowEditor" }); } if (owns("header")) { - updates.setOnChange(() => requestRender()); - updates.start(ctx.cwd); ctx.ui.setHeader((tui, theme) => { requestRender = (force = false) => tui.requestRender(force); // Header factory runs inside setHeader, before /new appends the stock chat copy. @@ -531,7 +523,7 @@ export default function piSlate(pi: ExtensionAPI): void { if (!noticeFilterActive) queueMicrotask(() => { if (mountedContext()) attachFilter(findChatContainer(layoutRootOf(tui))); }); - return new SlateHeader(theme, liveContext, columnWidth, () => updates.notice, () => { + return new SlateHeader(theme, liveContext, columnWidth, () => { const ctx = liveContext(); return { version: VERSION, @@ -775,7 +767,6 @@ export default function piSlate(pi: ExtensionAPI): void { mcpFiles.clear(); background.reset(""); branch.dispose(); - updates.dispose(); toolGroups?.dispose(); toolGroups = undefined; messageWindow?.dispose(); @@ -824,15 +815,6 @@ export default function piSlate(pi: ExtensionAPI): void { return PROGRESS_STYLES.find((s) => PROGRESS_LABELS[s] === key); }; - const pickLogo = async (ctx: ExtensionContext): Promise => { - const value = await ctx.ui.select("Header logo", LOGO_STYLES.map((style) => - withCurrent(style === "minimal" ? "Minimal (classic white Pi)" : "Original", (config.logo ?? "minimal") === style))); - const key = value ? withoutCurrent(value) : undefined; - if (key === "Original") return "original"; - if (key === "Minimal (classic white Pi)") return "minimal"; - return undefined; - }; - const pickDensity = async (ctx: ExtensionContext): Promise => { const value = await ctx.ui.select("Density", [ withCurrent("Comfortable", config.density === "comfortable"), @@ -1085,8 +1067,22 @@ export default function piSlate(pi: ExtensionAPI): void { }, }); + pi.registerCommand("logo", { + description: "Toggle the header logo, or set original/minimal", + getArgumentCompletions: logoArgumentCompletions, + handler: async (args, ctx) => { + const parsed = parseLogoArgs(args); + if (!parsed.ok) { + ctx.ui.notify(LOGO_USAGE, "error"); + return; + } + const logo = parsed.value ?? ((config.logo ?? "minimal") === "minimal" ? "original" : "minimal"); + apply({ ...config, logo }, logo === "minimal" ? "Header logo set to minimal (classic white Pi)" : "Header logo set to original", ctx); + }, + }); + pi.registerCommand("slate", { - description: "Session dashboard, surfaces, density, composer metadata, sidebar, focused mode, PID display, effort border color, header logo, progress indicator, message length, theme, or file a bug", + description: "Session dashboard, surfaces, density, composer metadata, sidebar, focused mode, PID display, effort border color, progress indicator, message length, theme, or file a bug", getArgumentCompletions: slateArgumentCompletions, handler: async (args, ctx) => { const parsed = parseSlateArgs(args); @@ -1097,7 +1093,7 @@ export default function piSlate(pi: ExtensionAPI): void { let kind = parsed.kind; if (kind === "menu") { - const setting = await ctx.ui.select("Slate", ["Session dashboard", "Surfaces", "Density", "Composer metadata", "Sidebar width", "Focused", "PID display", "Effort border color", "Header logo", "Progress indicator", "Message length", "Theme", "File a bug"]); + const setting = await ctx.ui.select("Slate", ["Session dashboard", "Surfaces", "Density", "Composer metadata", "Sidebar width", "Focused", "PID display", "Effort border color", "Progress indicator", "Message length", "Theme", "File a bug"]); if (setting === "Session dashboard") { await sessionMenu(ctx); return; } if (setting === "Surfaces") { const names = await ctx.ui.input("Surfaces (names separated by spaces, full, or none; reload required)", formatSurfaces(config.surfaces)); @@ -1113,7 +1109,6 @@ export default function piSlate(pi: ExtensionAPI): void { else if (setting === "Sidebar width") kind = "width-menu"; else if (setting === "Focused") kind = "focused"; else if (setting === "PID display") kind = "pid"; - else if (setting === "Header logo") kind = "logo"; else if (setting === "Progress indicator") kind = "progress"; else if (setting === "Message length") kind = "message-length-menu"; else if (setting === "Theme") kind = "theme-menu"; @@ -1174,13 +1169,6 @@ export default function piSlate(pi: ExtensionAPI): void { return; } - if (kind === "logo") { - const logo = (parsed.kind === "logo" ? parsed.value : undefined) ?? await pickLogo(ctx); - if (!logo) return; - apply({ ...config, logo }, logo === "minimal" ? "Header logo set to minimal (classic white Pi)" : "Header logo set to original", ctx); - return; - } - if (parsed.kind === "width") { apply(withSidebarPercent({ ...config, focused: false }, parsed.width), widthMessage(parsed.width), ctx); return; diff --git a/pi-slate/extensions/pi-slate/layout.ts b/pi-slate/extensions/pi-slate/layout.ts index 938b45b..8da01ab 100644 --- a/pi-slate/extensions/pi-slate/layout.ts +++ b/pi-slate/extensions/pi-slate/layout.ts @@ -2,14 +2,7 @@ import { readFileSync } from "node:fs"; import { parseSurfaces, SURFACES, type Surface } from "./surfaces.ts"; import { isProgressStyle, type ProgressStyle } from "./working-wave.ts"; -// The source SVG is a 4×4 square grid. Terminal cells are approximately twice -// as tall as they are wide, so every source square occupies two columns. -// Keep the empty fourth column on the first two rows. The header centers this -// fixed-width canvas as a whole; trimming those spaces distorts the mark. -export const PI_LOGO = ["██████ ", "██ ██ ", "████ ██", "██ ██"]; -export const PI_LOGO_ASCII = ["###### ", "## ## ", "#### ##", "## ##"]; - -/** Header mark variants: the classic white Pi or the stock brand mark. */ +/** Header mark variants: the classic monochrome Pi or the stock brand mark. */ export const LOGO_STYLES = ["original", "minimal"] as const; export type LogoStyle = typeof LOGO_STYLES[number]; @@ -80,41 +73,11 @@ export function modelStatusLabel( return base; } -const INTEGERS = new Intl.NumberFormat("en"); - -export function formatInteger(value: number | null | undefined): string { - if (value === null || value === undefined || !Number.isFinite(value)) return "—"; - return INTEGERS.format(Math.round(value)); -} - -export function formatTokenCount(tokens: number | null | undefined): string { - if (tokens === null || tokens === undefined || !Number.isFinite(tokens)) return "— tokens"; - return `${formatInteger(tokens)} tokens`; -} - -export function formatTokenRate(rate: number | null | undefined): string { - if (rate === null || rate === undefined || !Number.isFinite(rate)) return "— tokens/sec"; - return `${formatInteger(Math.max(0, rate))} tokens/sec`; -} - export function formatPercent(percent: number | null | undefined): string { if (percent === null || percent === undefined || !Number.isFinite(percent)) return "—%"; return `${Math.round(percent)}%`; } -export function formatSpend(cost: number | null | undefined): string { - if (cost === null || cost === undefined || !Number.isFinite(cost)) return "$0.00"; - return `$${Math.max(0, cost).toFixed(2)}`; -} - -export function formatContextTokens( - tokens: number | null | undefined, - percent: number | null | undefined, - rate: number | null | undefined, -): string { - return `${formatTokenCount(tokens)} · ${formatPercent(percent)} used · ${formatTokenRate(rate)}`; -} - /** Compact thousands: 845 -> "845", 16005 -> "16k", 47349 -> "47.3k", 1000000 -> "1M". */ export function formatCompactTokenCount(tokens: number | null | undefined): string { if (tokens === null || tokens === undefined || !Number.isFinite(tokens)) return "—"; @@ -140,74 +103,18 @@ export function formatFocusedContextTokens( return `${formatPercent(percent)}/${formatCompactTokenCount(contextWindow)} tokens · ↑↓${formatCompactTokenCount(rate)}`; } -export function formatMcpEnabled(count: number): string { - const servers = Math.max(0, Math.round(count)); - return `${servers} MCPs enabled`; -} - -export function formatSkillsLoaded(count: number): string { - const skills = Math.max(0, Math.round(count)); - return `${skills} skills loaded`; -} - -export function formatContextResources( - spend: number | null | undefined, - skills: number, - mcpCount: number | null, -): string { - return `${formatSpend(spend)} · ${formatSkillsLoaded(skills)} · ${formatMcpEnabled(mcpCount ?? 0)}`; -} - export function formatFocusedContextResources( - spend: number | null | undefined, - skills: number, + percent: number | null | undefined, + estimated: boolean, mcpCount: number | null, ): string { - const skillCount = Math.max(0, Math.round(skills)); const serverCount = mcpCount === null ? 0 : Math.max(0, Math.round(mcpCount)); - // Focused mode owns the only inline cost readout; keep it even when no - // skills or MCP servers are loaded. - const parts = [formatSpend(spend)]; - if (skillCount > 0) parts.push(`${skillCount} ${skillCount === 1 ? "skill" : "skills"}`); - if (serverCount > 0) parts.push(`${serverCount} ${serverCount === 1 ? "MCP" : "MCPs"}`); + const parts = [`ctx ${estimated && percent !== null ? "~" : ""}${formatPercent(percent)}`]; + if (serverCount > 0) parts.push(`${serverCount} mcp`); return parts.join(" · "); } -export function countSkillCommands(commands: readonly { source?: string; sourceInfo?: { path?: string }; name?: string }[]): number { - const seen = new Set(); - for (const command of commands) { - if (command.source !== "skill") continue; - seen.add(command.sourceInfo?.path || command.name || "skill"); - } - return seen.size; -} - -/** Later sources override the same server name (project over global). */ -export function mergeMcpServerMaps(...sources: unknown[]): Record { - const servers: Record = {}; - for (const data of sources) { - if (!data || typeof data !== "object" || Array.isArray(data)) continue; - const next = (data as { mcpServers?: unknown }).mcpServers; - if (!next || typeof next !== "object" || Array.isArray(next)) continue; - Object.assign(servers, next); - } - return servers; -} - -export function parseMcpEnabledCount(data: unknown): number | null { - if (!data || typeof data !== "object" || Array.isArray(data)) return null; - const servers = (data as { mcpServers?: unknown }).mcpServers; - if (!servers || typeof servers !== "object" || Array.isArray(servers)) return null; - return Object.values(servers).filter(isEnabledMcpServer).length; -} - -function isEnabledMcpServer(server: unknown): boolean { - if (!server || typeof server !== "object" || Array.isArray(server)) return false; - const entry = server as { enabled?: unknown; disabled?: unknown }; - return entry.enabled !== false && entry.disabled !== true; -} - export function footerVisibility(width: number): { showBranch: boolean; showModel: boolean; @@ -302,7 +209,9 @@ export const SLATE_VERSION = JSON.parse( ).version as string; export const SLATE_USAGE = - "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme | bug [file|open]"; + "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme | bug [file|open]"; + +export const LOGO_USAGE = "Usage: /logo [original|minimal]"; export function withCurrent(label: string, current: boolean): string { return current ? `${label} (current)` : label; @@ -339,9 +248,6 @@ const SLATE_COMPLETIONS = [ "effort-border", "effort-border on", "effort-border off", - "logo", - "logo original", - "logo minimal", "progress", "progress waves", "progress dna-helix", @@ -367,7 +273,6 @@ export type SlateArgs = | { ok: true; kind: "focused"; value?: boolean } | { ok: true; kind: "pid"; value?: boolean } | { ok: true; kind: "effort-border"; value?: boolean } - | { ok: true; kind: "logo"; value?: LogoStyle } | { ok: true; kind: "progress"; value?: ProgressStyle } | { ok: true; kind: "message-length-menu" } | { ok: true; kind: "message-length"; value?: number | "all" } @@ -433,11 +338,6 @@ export function parseSlateArgs(raw: string): SlateArgs { if (tail === "on" || tail === "off") return { ok: true, kind: "effort-border", value: tail === "on" }; return { ok: false }; } - if (head === "logo") { - if (!tail) return { ok: true, kind: "logo" }; - if (tail === "original" || tail === "minimal") return { ok: true, kind: "logo", value: tail }; - return { ok: false }; - } if (head === "progress") { if (!tail) return { ok: true, kind: "progress" }; if (isProgressStyle(tail)) return { ok: true, kind: "progress", value: tail }; @@ -471,6 +371,20 @@ export function slateArgumentCompletions(prefix: string): { value: string; label return matches.length ? matches : null; } +export function logoArgumentCompletions(prefix: string): { value: string; label: string }[] | null { + const normalized = prefix.trimStart().toLowerCase(); + const matches = LOGO_STYLES.filter((value) => value.startsWith(normalized)) + .map((value) => ({ value, label: value })); + return matches.length ? matches : null; +} + +export function parseLogoArgs(raw: string): { ok: true; value?: LogoStyle } | { ok: false } { + const words = raw.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (words.length === 0) return { ok: true }; + const value = LOGO_STYLES.find((style) => style === words[0]); + return words.length === 1 && value ? { ok: true, value } : { ok: false }; +} + export function clampSidebarColumns(totalWidth: number, columns: number): number { if (totalWidth < SIDEBAR_MIN_TERMINAL_WIDTH) return 0; return Math.max(SIDEBAR_MIN_WIDTH, Math.min(maxSidebarWidth(totalWidth), columns)); diff --git a/pi-slate/extensions/pi-slate/stock-notices.ts b/pi-slate/extensions/pi-slate/stock-notices.ts index 12b3ec6..9f52e48 100644 --- a/pi-slate/extensions/pi-slate/stock-notices.ts +++ b/pi-slate/extensions/pi-slate/stock-notices.ts @@ -3,12 +3,7 @@ import type { Component } from "@earendil-works/pi-tui"; const SAMPLE_WIDTH = 80; -export type NoticeContainer = { - children: Component[]; - removeChild(component: Component): void; -}; - -export function renderedPlain(component: Component, width = SAMPLE_WIDTH): string { +function renderedPlain(component: Component, width = SAMPLE_WIDTH): string { try { return component.render(width).map((line) => stripVTControlCharacters(line)).join("\n"); } catch { @@ -16,20 +11,7 @@ export function renderedPlain(component: Component, width = SAMPLE_WIDTH): strin } } -export function isStockUpdateNotice(component: Component): boolean { - const text = renderedPlain(component); - if (text.includes("Package Updates Available")) return true; - return text.includes("Update Available") && /New version |Changelog:|\bpi update\b/.test(text); -} - -export function isHairlineBorder(component: Component): boolean { - const lines = renderedPlain(component, 40).split("\n"); - if (lines.length !== 1) return false; - const plain = lines[0]?.trim() ?? ""; - return plain.length > 0 && /^─+$/.test(plain); -} - -export function isBlankSpacer(component: Component): boolean { +function isBlankSpacer(component: Component): boolean { const plain = renderedPlain(component, 10); return plain.trim() === ""; } @@ -45,7 +27,9 @@ export function isStockNewSessionNotice(component: Component): boolean { return body.length === 1 && body[0] === STOCK_NEW_SESSION_NOTICE && lines.length > 1; } -export type ChatContainer = NoticeContainer & { +export type ChatContainer = { + children: Component[]; + removeChild(component: Component): void; addChild(component: Component): void; }; @@ -83,37 +67,3 @@ export function installNewSessionNoticeFilter(container: ChatContainer): () => v container.addChild = wrapped; return dispose; } - -function removeOneStockNotice(container: NoticeContainer): boolean { - const { children } = container; - for (let i = 0; i < children.length; i++) { - const title = children[i]; - if (!title || !isStockUpdateNotice(title)) continue; - let close = -1; - for (let j = i + 1; j < children.length; j++) { - const next = children[j]; - if (next && isHairlineBorder(next)) { - close = j; - break; - } - } - if (close < 0) return false; - let start = i; - while (start > 0) { - const prev = children[start - 1]; - if (!prev || !(isHairlineBorder(prev) || isBlankSpacer(prev))) break; - start -= 1; - } - for (const node of children.slice(start, close + 1)) { - container.removeChild(node); - } - return true; - } - return false; -} - -export function sweepStockUpdateNotices(container: NoticeContainer): boolean { - let removed = false; - while (removeOneStockNotice(container)) removed = true; - return removed; -} diff --git a/pi-slate/extensions/pi-slate/updates.ts b/pi-slate/extensions/pi-slate/updates.ts deleted file mode 100644 index d1e8f21..0000000 --- a/pi-slate/extensions/pi-slate/updates.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { DefaultPackageManager, getAgentDir, SettingsManager, VERSION } from "@earendil-works/pi-coding-agent"; - -export type PiUpdate = { current: string; next: string }; - -export type UpdateNotice = { - pi?: PiUpdate; - packages: string[]; -}; - -const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; - -export function formatUpdateNotice(notice: UpdateNotice): string | undefined { - const names: string[] = []; - if (notice.pi) names.push(`pi ${notice.pi.current} → ${notice.pi.next}`); - for (const pkg of notice.packages) names.push(pkg); - if (names.length === 0) return undefined; - const label = names.length === 1 ? "update available" : `${names.length} updates available`; - const command = notice.pi ? "pi update" : "pi update --extensions"; - return `${label} · ${names.join(", ")} · ${command}`; -} - -export function isNewerVersion(candidate: string, current: string): boolean { - const left = parseVersion(candidate); - const right = parseVersion(current); - if (!left || !right) return candidate.trim() !== current.trim(); - for (let i = 0; i < Math.max(left.length, right.length); i++) { - const a = left[i] ?? 0; - const b = right[i] ?? 0; - if (a > b) return true; - if (a < b) return false; - } - return false; -} - -function parseVersion(value: string): number[] | undefined { - const core = value.trim().split(/[-+]/)[0] ?? ""; - if (!/^\d+(\.\d+)*$/.test(core)) return undefined; - return core.split(".").map(Number); -} - -export async function checkPiUpdate(current = VERSION): Promise { - if (process.env.PI_OFFLINE || process.env.PI_SKIP_VERSION_CHECK) return undefined; - try { - const response = await fetch(LATEST_VERSION_URL, { - headers: { accept: "application/json" }, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) return undefined; - const data = (await response.json()) as { version?: unknown }; - if (typeof data.version !== "string" || !data.version.trim()) return undefined; - const next = data.version.trim(); - if (!isNewerVersion(next, current)) return undefined; - return { current, next }; - } catch { - return undefined; - } -} - -export async function checkPackageUpdates(cwd: string): Promise { - if (process.env.PI_OFFLINE) return []; - try { - const agentDir = getAgentDir(); - const packageManager = new DefaultPackageManager({ - cwd, - agentDir, - settingsManager: SettingsManager.create(cwd, agentDir), - }); - const updates = await packageManager.checkForAvailableUpdates(); - return updates.map((update) => update.displayName); - } catch { - return []; - } -} - -export class UpdateWatcher { - notice: UpdateNotice = { packages: [] }; - private onChange: () => void = () => {}; - private generation = 0; - - setOnChange(fn: () => void): void { - this.onChange = fn; - } - - start(cwd: string): void { - void this.refresh(cwd); - } - - dispose(): void { - this.generation += 1; - this.onChange = () => {}; - } - - async refresh(cwd: string): Promise { - const generation = this.generation; - const [pi, packages] = await Promise.all([checkPiUpdate(), checkPackageUpdates(cwd)]); - if (generation !== this.generation) return; - this.notice = { - ...(pi ? { pi } : {}), - packages, - }; - this.onChange(); - } -} diff --git a/pi-slate/llms.txt b/pi-slate/llms.txt index 68684d6..88b3f39 100644 --- a/pi-slate/llms.txt +++ b/pi-slate/llms.txt @@ -20,7 +20,7 @@ Claude Code-style tool cards are the default with tool-cards selected; `toolStyl Highlighted diffs use responsive split edits, unified writes, and character-level emphasis. They follow the active Slate theme. Native model-facing results are preserved; bounded write patches are stored in TUI result details for session restore. -Header uses the stock Pi mark plus version, model, session dir as ~/, and an optional update hairline. Composer keeps project/branch on the bottom left, focused cost/resources on the top edge, and ctx before model on the bottom right, with square corners matching the fully bordered sidebar. Completed turns add a UI-only elapsed-time receipt with local finish time (Worked for 54s • 09:37). Image placeholders are a single caret object. The rail starts with a live session title in bold theme accent color (click copies the full plain-text title; width-aware ellipsis only affects painting; unnamed sessions leave a blank row), followed by Session · PID (click copies the full ID). Titles come from Pi's /name or a naming extension; Slate never generates them. Renames update the title through session_info_changed, and /slate session includes the full title. The remaining rail shows context, a merged tokens/cost/rate occupancy line, left-lane Cached/Uncached and right-lane value-first In/Out rows, folded MCP/Skills/Prompts (Enabled/Loaded counts, MCP error counts in red), and live background shells. Commands stay behind / and /slate session commands. Empty task/image shelves disappear. Below 100 columns or 30 rows the rail hides without changing preferences; focused cost/resources, project/branch, and context/model return to the composer without duplicating cost. Pin/inspection and session/root ownership protections remain intact. +Header uses the stock Pi mark plus version, model, and session dir as ~/<name>. Composer keeps project/branch on the bottom left, focused ctx/resources on the top edge, and model on the bottom right, with square corners matching the fully bordered sidebar. Completed turns add a UI-only elapsed-time receipt with local finish time (Worked for 54s • 09:37). Image placeholders are a single caret object. The rail starts with a live session title in bold theme accent color (click copies the full plain-text title; width-aware ellipsis only affects painting; unnamed sessions leave a blank row), followed by Session <short-id> · PID <pid> (click copies the full ID). Titles come from Pi's /name <title> or a naming extension; Slate never generates them. Renames update the title through session_info_changed, and /slate session includes the full title. The remaining rail shows context, a merged tokens/cost/rate occupancy line, left-lane Cached/Uncached and right-lane value-first In/Out rows, folded MCP/Skills/Prompts (Enabled/Loaded counts, MCP error counts in red), and live background shells. Commands stay behind / and /slate session commands. Empty task/image shelves disappear. Below 100 columns or 30 rows the rail hides without changing preferences; focused ctx/resources, project/branch, and model return to the composer. Pin/inspection and session/root ownership protections remain intact. ## Documentation diff --git a/pi-slate/tests/composer.test.ts b/pi-slate/tests/composer.test.ts index 2d12116..f061271 100644 --- a/pi-slate/tests/composer.test.ts +++ b/pi-slate/tests/composer.test.ts @@ -116,28 +116,30 @@ test("composer labels hide model on minimal footer and at narrow widths", () => test("focused context sits on the composer top edge", () => { const line = composerContextEdge( - "$7.47 · 14 skills · 2 MCPs", + "ctx 11% · 2 mcp", 80, (text) => text, ); assert.equal(line[0], "┌"); assert.equal(line.at(-1), "┐"); assert.doesNotMatch(line, /tokens/); - assert.match(line, /\$7\.47/); - const squeezed = composerContextEdge("$7.47 · 14 skills", 28, (text) => text, 3); + assert.match(line, /ctx 11% · 2 mcp/); + const squeezed = composerContextEdge("ctx 11% · 2 mcp", 28, (text) => text, 3); assert.equal(visibleWidth(squeezed), 28); assert.match(squeezed, /↑ 3 more/); }); -test("focused prompt edge keeps cost, hides empty resource counts, and shows loaded skills", () => { - const empty = composerContextEdge(formatFocusedContextResources(1.234, 0, 0), 40, (text) => text); +test("focused prompt edge shows ctx and mcp without zero counts", () => { + const empty = composerContextEdge(formatFocusedContextResources(10, false, 0), 40, (text) => text); assert.equal(visibleWidth(empty), 40); - assert.match(empty, /\$1\.23/); - assert.doesNotMatch(empty, /0 skills|0 MCP/); + assert.match(empty, /ctx 10%/); + assert.doesNotMatch(empty, /0 skills|0 mcp/); - const withSkill = composerContextEdge(formatFocusedContextResources(1.234, 1, 0), 80, (text) => text); - assert.match(withSkill, /\$1\.23 · 1 skill/); - assert.doesNotMatch(withSkill, /loaded|enabled|0 MCP|0 skills/); + const withMcp = composerContextEdge(formatFocusedContextResources(10, false, 2), 80, (text) => text); + assert.match(withMcp, /ctx 10% · 2 mcp/); + + const estimated = composerContextEdge(formatFocusedContextResources(10, true, 1), 80, (text) => text); + assert.match(estimated, /ctx ~10% · 1 mcp/); }); test("focused composer token label uses compact percent placement", () => { @@ -151,6 +153,48 @@ test("focused composer token label uses compact percent placement", () => { assert.doesNotMatch(labels.right, /t\/s|5% used/); }); +test("composer metadata separators match the surrounding metadata tone", () => { + const labels = composerLabels( + { + project: "pi-configs", + branch: "main", + model: "grok-4.6", + thinking: "medium", + tokens: "0 tokens", + footer: "standard", + }, + labelTheme, + 140, + ); + assert.match(labels.right, /\[muted\] · /); + assert.doesNotMatch(labels.right, /\[borderMuted\]/); +}); + +test("focused composer bottom edge drops ctx and uses readable separators", () => { + const editor = new ComposerEditor( + { terminal: { rows: 24, columns: 100 }, requestRender() {} } as TUI, + { borderColor: (text) => text } as EditorTheme, + new KeybindingsManager(), + () => ({ + project: "slate", + branch: null, + model: { id: "sample", provider: "sample" }, + thinking: "medium", + footer: "standard", + theme: labelTheme, + dashboardVisible: false, + context: { tokens: "10%/100k tokens", resources: "ctx 10% · 2 mcp" }, + }), + ); + const lines = editor.render(100); + assert.match(lines[0]!, /\[dim\]ctx 10%\[dim\] · \[dim\]2 mcp/); + const bottom = lines.at(-1)!; + assert.doesNotMatch(bottom, /ctx 10%/); + assert.match(bottom, /\[muted\] · /); + assert.match(bottom, /sample/); + assert.doesNotMatch(bottom, /borderMuted/); +}); + const labelTheme = { fg: (name: string, text: string) => `[${name}]${text}`, italic: (text: string) => `{i}${text}`, @@ -175,7 +219,7 @@ test("composerStatusLabel keeps Pi colors for other status kinds", () => { test("working status stays left of the focused context edge", () => { const line = composerStatusContextEdge( - "$7.47 · 14 skills · 2 MCPs", + "ctx 11% · 2 mcp", 80, (text) => text, 0, @@ -183,13 +227,13 @@ test("working status stays left of the focused context edge", () => { ); assert.equal(visibleWidth(line), 80); assert.match(line, /^┌── pondering\.\.\./); - assert.ok(line.indexOf("pondering") < line.indexOf("$7.47")); - assert.match(line, /\$7\.47 · 14 skills · 2 MCPs ┐$/); + assert.ok(line.indexOf("pondering") < line.indexOf("ctx 11%")); + assert.match(line, /ctx 11% · 2 mcp ┐$/); }); test("live status keeps its label when resources are long", () => { const line = composerStatusContextEdge( - "$7.47 · 14 skills · 2 MCPs", + "ctx 11% · extra long resource · 2 mcp", 64, (text) => text, 0, @@ -202,16 +246,16 @@ test("live status keeps its label when resources are long", () => { const plain = stripVTControlCharacters(line); assert.equal(visibleWidth(line), 64); assert.match(plain, /Retrying \(2\/5\)/); - assert.ok(!plain.includes("2 MCPs")); + assert.ok(!plain.includes("2 mcp")); }); test("top edge right suffix stays pinned and survives truncation", () => { const pid = `PID-${process.pid}`; - const wide = composerStatusContextEdge("$7.47 · 14 skills · 2 MCPs", 80, (text) => text, 0, "", undefined, pid); + const wide = composerStatusContextEdge("ctx 11% · 2 mcp", 80, (text) => text, 0, "", undefined, pid); assert.equal(visibleWidth(wide), 80); - assert.match(wide, new RegExp(`2 MCPs +${pid} ┐$`)); + assert.match(wide, new RegExp(`2 mcp +${pid} ┐$`)); - const narrow = composerStatusContextEdge("$7.47 · 14 skills · 2 MCPs", 30, (text) => text, 0, "", undefined, pid); + const narrow = composerStatusContextEdge("ctx 11% · 2 mcp", 30, (text) => text, 0, "", undefined, pid); assert.equal(visibleWidth(narrow), 30); assert.ok(stripVTControlCharacters(narrow).endsWith(`${pid} ┐`)); @@ -223,7 +267,7 @@ test("top edge right suffix stays pinned and survives truncation", () => { test("top edge fits every width with ANSI and wide suffixes", () => { for (const suffix of ["PID-123456", "\x1b[90mPID-123456\x1b[0m", "進行中🚀"]) { for (let width = 0; width <= 80; width++) { - const line = composerStatusContextEdge("$7.47 · 14 skills", width, (text) => text, 12, "Retrying...", undefined, suffix); + const line = composerStatusContextEdge("ctx 11%", width, (text) => text, 12, "Retrying...", undefined, suffix); assert.equal(visibleWidth(line), width, `width ${width}: ${JSON.stringify(line)}`); } } @@ -241,13 +285,13 @@ test("live status receives only its body budget and renders once", () => { }); test("overflow count stays visible before static resource decorations", () => { - const line = composerStatusContextEdge("$7.47 · 14 skills · 2 MCPs", 30, (text) => text, 12); + const line = composerStatusContextEdge("ctx 11% · 2 mcp", 30, (text) => text, 12); assert.match(line, /↑ 12 more/); assert.equal(visibleWidth(line), 30); }); test("composer top border omits the PID suffix when PID display is off", () => { - const off = composerStatusContextEdge("$7.47 · 14 skills", 80, (text) => text, 0, "", undefined, ""); + const off = composerStatusContextEdge("ctx 11%", 80, (text) => text, 0, "", undefined, ""); assert.doesNotMatch(stripVTControlCharacters(off), /PID-/); assert.ok(stripVTControlCharacters(off).endsWith("┐")); }); diff --git a/pi-slate/tests/hairline.test.ts b/pi-slate/tests/hairline.test.ts deleted file mode 100644 index 78ea186..0000000 --- a/pi-slate/tests/hairline.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { stripVTControlCharacters } from "node:util"; -import { visibleWidth } from "@earendil-works/pi-tui"; -import { - centeredHairlines, - dashCount, - hairlineTextWidth, - symmetricHairline, - wrapHairlineText, -} from "../extensions/pi-slate/hairline.ts"; - -function sides(line: string): { left: number; right: number; text: string } { - const plain = stripVTControlCharacters(line); - const start = plain.search(/[^\s─]/); - const end = [...plain].reduce((last, ch, i) => (ch !== " " && ch !== "─" ? i + 1 : last), start); - return { - left: [...plain.slice(0, start)].filter((ch) => ch === "─").length, - right: [...plain.slice(end)].filter((ch) => ch === "─").length, - text: plain.slice(start, end), - }; -} - -test("hairline dashes stay equal on even and odd widths", () => { - for (const width of [40, 41, 80, 81]) { - const line = symmetricHairline("update available", width); - assert.equal(visibleWidth(line), width); - const { left, right, text } = sides(line); - assert.equal(left, right); - assert.equal(left, dashCount(visibleWidth(text), width)); - assert.equal(text, "update available"); - } -}); - -test("narrow hairlines stay symmetric after truncating", () => { - const line = symmetricHairline("update available", 17); - assert.equal(visibleWidth(line), 17); - const { left, right } = sides(line); - assert.equal(left, right); -}); - -test("painted hairline still measures to the requested width", () => { - const line = symmetricHairline("\x1b[38;2;203;166;247mupdate available\x1b[39m", 48, (dash) => `\x1b[90m${dash}\x1b[39m`); - assert.equal(visibleWidth(line), 48); - const { left, right } = sides(line); - assert.equal(left, right); -}); - -test("wraps on separators and keeps each line symmetric", () => { - const notice = "3 updates available · pi-whiteboard, pi-ask, pi-subagents · pi update --extensions"; - const width = 42; - const lines = centeredHairlines(notice, width); - assert.ok(lines.length > 1); - for (const line of lines) { - assert.equal(visibleWidth(line), width); - const { left, right } = sides(line); - assert.equal(left, right); - } - assert.ok(wrapHairlineText(notice, hairlineTextWidth(width)).every((line) => ( - visibleWidth(line) <= hairlineTextWidth(width) - ))); -}); diff --git a/pi-slate/tests/header.test.ts b/pi-slate/tests/header.test.ts index d1f4ad4..649a0af 100644 --- a/pi-slate/tests/header.test.ts +++ b/pi-slate/tests/header.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { stripVTControlCharacters } from "node:util"; -import { visibleWidth } from "@earendil-works/pi-tui"; +import { CombinedAutocompleteProvider, visibleWidth } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { piLogoLines, piMinimalLogoLines, piWordmark, renderSlateHeader, supportsPiLogo } from "../extensions/pi-slate/header.ts"; -import { parseSlateArgs } from "../extensions/pi-slate/layout.ts"; +import { logoArgumentCompletions, parseLogoArgs } from "../extensions/pi-slate/layout.ts"; const theme = { fg: (_name: string, text: string) => text, bold: (text: string) => text } as unknown as Theme; @@ -26,18 +26,6 @@ test("header uses the stock Pi mark and the product stack, not a greeting", () = assert(plain.every(line => visibleWidth(line) <= 72)); }); -test("optional update hairline remains bounded and no session chip is rendered", () => { - const lines = renderSlateHeader({ - width: 72, path: "/tmp/project", notice: "update available · pi update --extensions", - identity: { version: "1.1.0" }, logo: false, theme, - }); - const plain = lines.map(stripVTControlCharacters); - assert.match(plain.join("\n"), /update available/); - assert.doesNotMatch(plain.join("\n"), /New session started/); - assert.doesNotMatch(plain.join("\n"), /Ask a question/); - assert(plain.every(line => visibleWidth(line) <= 72)); -}); - test("wordmark fallback stays compact", () => { const started = renderSlateHeader({ width: 40, path: "/tmp/project", identity: { version: "1.0.4" }, logo: false, theme }); assert.doesNotMatch(started.join("\n"), /New session started|Ready when/); @@ -56,19 +44,18 @@ test("identity truncates safely and strips untrusted terminal controls", () => { } }); -test("minimal logo renders the classic white Pi mark beside the product stack", () => { +test("minimal logo matches the stock mark's 4×2 geometry in white", () => { const lines = renderSlateHeader({ width: 72, path: "/Users/operator/GitHub/pi-extensions", identity: { version: "1.0.4", model: "grok-4.6 (bedrock)", thinking: "medium" }, logo: true, logoStyle: "minimal", theme, }); const plain = lines.map(stripVTControlCharacters); - assert.equal(plain.length, 4); - assert.match(plain[0]!, /^██████\s+Pi Agent v1\.0\.4/); - assert.match(plain[1]!, /^██ {2}██\s+grok-4\.6 \(bedrock\) · medium/); - assert.match(plain[2]!, /^████ {2}██ ~\/pi-extensions/); - assert.match(plain[3]!, /^██ {4}██/); - assert(lines.every(line => line.includes("\x1b[")), "mark stays painted white"); + assert.equal(plain.length, 3); + assert.match(plain[0]!, /^█▀█\s+Pi Agent v1\.0\.4/); + assert.match(plain[1]!, /^█▀ █\s+grok-4\.6 \(bedrock\) · medium/); + assert.match(plain[2]!, /^ {5}~\/pi-extensions/); + assert(lines.slice(0, 2).every(line => line.includes("\x1b[")), "mark stays painted white"); assert(plain.every(line => visibleWidth(line) <= 72)); }); @@ -77,22 +64,46 @@ test("minimal is the default logo style", () => { width: 72, path: "/tmp/project", identity: { version: "1.0.4" }, logo: true, theme, }); const plain = lines.map(stripVTControlCharacters); - assert.equal(plain.length, 4); - assert.match(plain[0]!, /^██████/); + assert.equal(plain.length, 3); + assert.match(plain[0]!, /^█▀█/); }); -test("logo style parses through /slate logo", () => { - assert.deepEqual(parseSlateArgs("logo"), { ok: true, kind: "logo" }); - assert.deepEqual(parseSlateArgs("logo minimal"), { ok: true, kind: "logo", value: "minimal" }); - assert.deepEqual(parseSlateArgs("logo original"), { ok: true, kind: "logo", value: "original" }); - assert.deepEqual(parseSlateArgs("logo retro"), { ok: false }); - assert.deepEqual(parseSlateArgs("logo minimal extra"), { ok: false }); +test("logo command parses original, minimal, and the toggle", () => { + assert.deepEqual(parseLogoArgs(""), { ok: true }); + assert.deepEqual(parseLogoArgs("minimal"), { ok: true, value: "minimal" }); + assert.deepEqual(parseLogoArgs(" ORIGINAL "), { ok: true, value: "original" }); + assert.deepEqual(parseLogoArgs("retro"), { ok: false }); + assert.deepEqual(parseLogoArgs("minimal extra"), { ok: false }); + assert.deepEqual(logoArgumentCompletions("m"), [{ value: "minimal", label: "minimal" }]); + assert.deepEqual(logoArgumentCompletions(" "), [ + { value: "original", label: "original" }, + { value: "minimal", label: "minimal" }, + ]); + assert.equal(logoArgumentCompletions("retro"), null); +}); + +test("logo argument completion keeps the command separator", async () => { + const provider = new CombinedAutocompleteProvider([ + { name: "logo", getArgumentCompletions: logoArgumentCompletions }, + ], process.cwd()); + for (const text of ["/logo ", "/logo m", "/logo m"]) { + const suggestions = await provider.getSuggestions([text], 0, text.length, { force: false, signal: new AbortController().signal }); + assert.ok(suggestions); + const item = suggestions.items.find(item => item.value === "minimal")!; + const result = provider.applyCompletion([text], 0, text.length, item, suggestions.prefix); + assert.match(result.lines[0]!, /^\/logo +minimal$/); + assert.deepEqual(parseLogoArgs(result.lines[0]!.slice(5)), { ok: true, value: "minimal" }); + } }); -test("minimal logo helpers match the classic bitmap", () => { - const lines = piMinimalLogoLines(theme); - assert.equal(lines.length, 4); - assert(lines.every(line => visibleWidth(line) === 8)); +test("logo helpers share the same 4×2 grid", () => { + const minimal = piMinimalLogoLines(theme); + const stock = piLogoLines(theme); + assert.equal(minimal.length, 2); + assert(minimal.every(line => visibleWidth(line) === 4)); + assert.equal(stock.length, 2); + assert(stock.every(line => visibleWidth(line) === 4)); + assert.deepEqual(minimal.map(stripVTControlCharacters), ["█▀█ ", "█▀ █"]); }); test("logo helpers keep the official bitmap and skip Apple Terminal", () => { diff --git a/pi-slate/tests/layout.test.mjs b/pi-slate/tests/layout.test.mjs index 797737a..a6739eb 100644 --- a/pi-slate/tests/layout.test.mjs +++ b/pi-slate/tests/layout.test.mjs @@ -1,28 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - PI_LOGO, - PI_LOGO_ASCII, paintLogo, centerOffset, compactDisplayText, compactPath, footerVisibility, - countSkillCommands, - formatContextResources, - formatContextTokens, formatCompactTokenCount, - formatInteger, - formatMcpEnabled, formatPercent, - formatSkillsLoaded, - formatSpend, - formatTokenCount, - formatTokenRate, formatFocusedContextResources, formatFocusedContextTokens, - mergeMcpServerMaps, - parseMcpEnabledCount, mainColumnWidth, maxSidebarWidth, modelLabel, @@ -60,12 +47,7 @@ import { workspacePaneSlots, } from "../extensions/pi-slate/workspace-layout.ts"; -test("logo preserves the official four-row geometry and terminal aspect ratio", () => { - assert.deepEqual(PI_LOGO, ["██████ ", "██ ██ ", "████ ██", "██ ██"]); - assert.deepEqual(PI_LOGO_ASCII, ["###### ", "## ## ", "#### ##", "## ##"]); - assert.ok(PI_LOGO.every((line) => line.length === 8)); - assert.ok(PI_LOGO_ASCII.every((line) => line.length === 8)); - assert.ok(PI_LOGO_ASCII.every((line) => /^[ #]+$/.test(line))); +test("logo paint stays white in every theme", () => { assert.equal(paintLogo("π"), "\x1b[38;2;255;255;255mπ\x1b[39m"); assert.equal(paintLogo("π", false), "\x1b[97mπ\x1b[39m"); }); @@ -97,18 +79,9 @@ test("footer progressively reveals optional metadata", () => { }); }); -test("sidebar context labels match the OpenCode-style facts", () => { - assert.equal(formatInteger(18958), "18,958"); - assert.equal(formatTokenCount(18958), "18,958 tokens"); - assert.equal(formatTokenCount(null), "— tokens"); - assert.equal(formatTokenRate(42.4), "42 tokens/sec"); - assert.equal(formatTokenRate(null), "— tokens/sec"); +test("percent labels round and handle missing values", () => { assert.equal(formatPercent(2.4), "2%"); assert.equal(formatPercent(null), "—%"); - assert.equal(formatSpend(1.234), "$1.23"); - assert.equal(formatSpend(null), "$0.00"); - assert.equal(formatContextTokens(3485, 2.4, 42.4), "3,485 tokens · 2% used · 42 tokens/sec"); - assert.equal(formatContextTokens(null, null, null), "— tokens · —% used · — tokens/sec"); }); test("compact token counts handle missing values and rounding across units", () => { @@ -124,7 +97,6 @@ test("focused context tokens use compact percent placement", () => { assert.equal(formatFocusedContextTokens(2.4, 42.4, 250_000), "2%/250k tokens · ↑↓42"); assert.equal(formatFocusedContextTokens(2.4, 42.4, 1_000_000), "2%/1M tokens · ↑↓42"); assert.equal(formatFocusedContextTokens(null, null, null), "—%/— tokens · ↑↓—"); - assert.equal(formatContextTokens(3485, 2.4, 42.4), "3,485 tokens · 2% used · 42 tokens/sec"); }); test("model label stays safe with missing data", () => { @@ -306,60 +278,12 @@ test("sidebar rows pin the footer dock and give the rest to content", () => { assert.deepEqual(sidebarRowSlots(20, SIDEBAR_DOCK_LINES), { contentHeight: 16, dockHeight: 4 }); }); -test("MCP and skill counts share the Context resource line", () => { - assert.equal(formatMcpEnabled(0), "0 MCPs enabled"); - assert.equal(formatMcpEnabled(1), "1 MCPs enabled"); - assert.equal(formatMcpEnabled(2), "2 MCPs enabled"); - assert.equal(formatSkillsLoaded(0), "0 skills loaded"); - assert.equal(formatSkillsLoaded(3), "3 skills loaded"); - assert.equal(formatContextResources(1.234, 3, 2), "$1.23 · 3 skills loaded · 2 MCPs enabled"); - assert.equal(formatContextResources(null, 0, null), "$0.00 · 0 skills loaded · 0 MCPs enabled"); - assert.equal(countSkillCommands([ - { source: "skill", sourceInfo: { path: "/skills/a/SKILL.md" }, name: "a" }, - { source: "skill", sourceInfo: { path: "/skills/a/SKILL.md" }, name: "a:1" }, - { source: "extension", sourceInfo: { path: "/ext.ts" }, name: "slate" }, - ]), 1); - assert.equal(parseMcpEnabledCount({ - mcpServers: { a: {}, b: {} }, - }), 2); - assert.equal(parseMcpEnabledCount({}), null); - assert.equal(parseMcpEnabledCount(null), null); -}); - -test("focused context resources keep cost and omit zero skill and MCP counts", () => { - assert.equal(formatFocusedContextResources(1.234, 0, 0), "$1.23"); - assert.equal(formatFocusedContextResources(1.234, 0, null), "$1.23"); - assert.equal(formatFocusedContextResources(null, 0, 0), "$0.00"); - assert.equal(formatFocusedContextResources(1.234, 3, 0), "$1.23 · 3 skills"); - assert.equal(formatFocusedContextResources(1.234, 0, 2), "$1.23 · 2 MCPs"); - assert.equal(formatFocusedContextResources(1.234, 1, 1), "$1.23 · 1 skill · 1 MCP"); - assert.equal(formatContextResources(null, 0, null), "$0.00 · 0 skills loaded · 0 MCPs enabled"); -}); - -test("MCP enabled count reads mcp.json and lets project override global", () => { - assert.equal(parseMcpEnabledCount({ - mcpServers: { - linear: { url: "https://mcp.linear.app/mcp" }, - github: { url: "https://api.githubcopilot.com/mcp", enabled: false }, - }, - }), 1); - assert.equal(parseMcpEnabledCount({ - mcpServers: { linear: { url: "https://mcp.linear.app/mcp" } }, - }), 1); - assert.equal(parseMcpEnabledCount({ connectedCount: 2 }), null); - assert.deepEqual( - mergeMcpServerMaps( - { mcpServers: { linear: { enabled: true }, github: {} } }, - { mcpServers: { linear: { enabled: false } } }, - ), - { linear: { enabled: false }, github: {} }, - ); - assert.equal(parseMcpEnabledCount({ - mcpServers: mergeMcpServerMaps( - { mcpServers: { linear: {}, github: {} } }, - { mcpServers: { github: { enabled: false } } }, - ), - }), 1); +test("focused context resources lead with ctx and use lowercase mcp", () => { + assert.equal(formatFocusedContextResources(null, false, 0), "ctx —%"); + assert.equal(formatFocusedContextResources(10, false, 0), "ctx 10%"); + assert.equal(formatFocusedContextResources(10, true, 0), "ctx ~10%"); + assert.equal(formatFocusedContextResources(10, false, 1), "ctx 10% · 1 mcp"); + assert.equal(formatFocusedContextResources(10, false, 2), "ctx 10% · 2 mcp"); }); test("files widget stays compact and never exceeds five lines", () => { diff --git a/pi-slate/tests/quiet-workbench.test.ts b/pi-slate/tests/quiet-workbench.test.ts index 38a2173..d3b81dd 100644 --- a/pi-slate/tests/quiet-workbench.test.ts +++ b/pi-slate/tests/quiet-workbench.test.ts @@ -120,7 +120,7 @@ test("context meter keeps a thin track and dim separator with the real theme", ( test("quiet editor avoids duplicate identity/model and restores compact facts without losing its draft", () => { const source: ComposerSource = { project: "project", branch: "feat/workbench", model: { id: "sample-model", provider: "sample" }, thinking: "medium", footer: "standard", theme, paddingX: 4, workspaceHeader: true, dashboardVisible: true, - context: { tokens: "11%/500k tokens", resources: "$1.24 · 2 skills · 2 MCPs", summary: "ctx 11%" } }; + context: { tokens: "11%/500k tokens", resources: "ctx 11% · 2 mcp" } }; const editor = new ComposerEditor({ terminal: { rows: 40, columns: 140 }, requestRender() {} } as unknown as TUI, { borderColor: text => text } as EditorTheme, new KeybindingsManager(), () => source, { paddingX: 4 }); editor.setPaddingX(0); // Native host copies its own padding after the factory returns. @@ -137,14 +137,14 @@ test("quiet editor avoids duplicate identity/model and restores compact facts wi const lines = editor.render(width); assert(lines.every(line => visibleWidth(line) <= width)); text = lines.map(stripVTControlCharacters).join("\n"); - assert.equal((text.match(/\$1\.24/g) ?? []).length, 1, "cost remains only on the top resource edge"); assert.match(text, /ctx 11%/); + assert.doesNotMatch(stripVTControlCharacters(lines.at(-1)!), /ctx 11%/); assert.match(text, /project/); if (width >= 42) assert.match(text, /project \/ feat\/workbench/); if (width >= 66) { const bottom = stripVTControlCharacters(lines.at(-1)!); - assert.match(bottom, /ctx 11% · sample-model/); - assert.ok(bottom.indexOf("ctx 11%") < bottom.indexOf("sample-model")); + assert.match(bottom, /sample-model/); + assert.doesNotMatch(bottom, /ctx 11%/); } } assert.equal(editor.getText(), "Preserve this draft"); diff --git a/pi-slate/tests/stock-notices.test.ts b/pi-slate/tests/stock-notices.test.ts index 4529fc7..1262357 100644 --- a/pi-slate/tests/stock-notices.test.ts +++ b/pi-slate/tests/stock-notices.test.ts @@ -5,80 +5,8 @@ import { MessageWindow } from "../extensions/pi-slate/message-window.ts"; import { installNewSessionNoticeFilter, isStockNewSessionNotice, - renderedPlain, - sweepStockUpdateNotices, } from "../extensions/pi-slate/stock-notices.ts"; -class Hairline { - invalidate(): void {} - render(width: number): string[] { - return ["─".repeat(Math.max(1, width))]; - } -} - -function addPackageCard(chat: Container, name: string): void { - chat.addChild(new Spacer(1)); - chat.addChild(new Hairline()); - chat.addChild(new Text( - `Package Updates Available\nPackage updates are available. Run pi update --extensions\nPackages:\n- ${name}`, - 1, - 0, - )); - chat.addChild(new Hairline()); -} - -function addPiCard(chat: Container, version: string): void { - chat.addChild(new Spacer(1)); - chat.addChild(new Hairline()); - chat.addChild(new Text( - `Update Available\nNew version ${version} is available. Run pi update`, - 1, - 0, - )); - chat.addChild(new Text("Changelog: https://pi.dev/changelog", 1, 0)); - chat.addChild(new Hairline()); -} - -test("sweeps complete package and pi update cards, waits for the closing rule", () => { - const chat = new Container(); - const keep = new Text("hello", 0, 0); - chat.addChild(keep); - chat.addChild(new Spacer(1)); - chat.addChild(new Hairline()); - chat.addChild(new Text("Package Updates Available\nRun pi update --extensions", 1, 0)); - assert.equal(chat.children.length, 4); - assert.equal(sweepStockUpdateNotices(chat), false); - chat.addChild(new Hairline()); - assert.equal(sweepStockUpdateNotices(chat), true); - assert.deepEqual(chat.children, [keep]); - - addPiCard(chat, "0.99.2"); - assert.equal(sweepStockUpdateNotices(chat), true); - assert.deepEqual(chat.children, [keep]); -}); - -test("message window preserves stock notices as they are added", () => { - const chat = new Container(); - const keep = new Text("user", 0, 0); - chat.addChild(keep); - const window = new MessageWindow(chat, 100); - addPackageCard(chat, "@dev.fast/pi-whiteboard"); - addPiCard(chat, "0.99.2"); - const all = [...chat.children]; - assert.equal(all.length, 10); - assert.equal(all[0], keep); - window.dispose(); - assert.deepEqual(chat.children, all); -}); - -test("does not remove ordinary chat that mentions updates", () => { - const chat = new Container(); - const note = new Text("I saw an Update Available banner yesterday", 0, 0); - chat.addChild(note); - assert.equal(sweepStockUpdateNotices(chat), false); - assert.deepEqual(chat.children, [note]); -}); - // Core handleClearCommand adds Spacer(1) then ThemedText themed via accent. function addStockNewSessionBanner(chat: Container): void { chat.addChild(new Spacer(1)); @@ -106,7 +34,7 @@ test("filter suppresses the stock new-session banner and its padding spacer", () assert.deepEqual(chat.children, [keep]); }); -test("filter keeps unrelated text and update cards, and installs once", () => { +test("filter keeps unrelated text and installs once", () => { const chat = new Container(); installNewSessionNoticeFilter(chat); const wrapped = chat.addChild; @@ -114,10 +42,7 @@ test("filter keeps unrelated text and update cards, and installs once", () => { assert.equal(chat.addChild, wrapped); const mention = new Text("notes about ✓ New session started", 0, 0); chat.addChild(mention); - addPiCard(chat, "0.99.2"); - assert.equal(chat.children.length, 6); - assert.equal(chat.children[0], mention); - assert.match(chat.children.map((child) => renderedPlain(child)).join("\n"), /Update Available/); + assert.deepEqual(chat.children, [mention]); }); test("filter composes with the message window in either install order", () => { diff --git a/pi-slate/tests/surfaces-install.test.ts b/pi-slate/tests/surfaces-install.test.ts index e7d886d..0b97bae 100644 --- a/pi-slate/tests/surfaces-install.test.ts +++ b/pi-slate/tests/surfaces-install.test.ts @@ -188,23 +188,19 @@ for (const surfaces of [[], ["editor"], [...FRESH_SURFACES], [...SURFACES], ["he }); } -test("focused composer keeps cost only on top and places context before model", async (t) => { +test("focused composer shows ctx and resources on top without duplicating them below", async (t) => { const f = await fixture(t, JSON.stringify({ surfaces: ["editor", "header"], focused: true })); Object.assign(f.ctx, { getContextUsage: () => ({ tokens: 10000, percent: 10, contextWindow: 100000 }) }); - f.extraCommands.push({ name: "skill:review", source: "skill", sourceInfo: { path: "/skills/review/SKILL.md", scope: "user" } }); f.manager.appendMessage({ role: "assistant", api: "anthropic-messages", provider: "anthropic", model: "fixture", timestamp: 1, stopReason: "stop", content: [{ type: "text", text: "done" }], usage: { input: 100, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 120, cost: { input: .1, output: .2, cacheRead: 0, cacheWrite: .05, total: .35 } } }); await f.emit("agent_settled"); await new Promise(resolve => setImmediate(resolve)); const lines = f.composer!.render(100).map(stripVTControlCharacters); - const text = lines.join("\n"); const bottom = lines.at(-1)!; - assert.equal((text.match(/\$0\.35/g) ?? []).length, 1, "top resource edge owns cost"); - assert.match(lines[0]!, /\$0\.35 · 1 skill/); + assert.match(lines[0]!, /ctx 10%/); assert.match(bottom, new RegExp(`${basename(f.ctx.cwd)} / test-branch`)); - assert.match(bottom, /ctx 10%/); - assert.ok(bottom.indexOf("ctx 10%") > bottom.indexOf("test-branch")); - assert.ok(bottom.indexOf("ctx 10%") < bottom.indexOf("no model")); + assert.match(bottom, /no model/); + assert.doesNotMatch(bottom, /ctx 10%/); }); test("Claude tool style is the default for tool-cards and skips legacy read grouping", async (t) => { @@ -411,6 +407,24 @@ test("/theme opens directly, retains aliases, and rejects unknown arguments", as assert.equal(f.commands.has("settings"), false, "core settings remain untouched"); }); +test("/logo toggles without a picker, saves explicit styles, and rejects unknown arguments", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["header"], logo: "minimal" })); + const command = f.commands.get("logo")!; + const saved = async () => JSON.parse(await readFile(f.path, "utf8")); + t.mock.method(f.ctx.ui, "select", async () => { assert.fail("/logo must not open a picker"); }); + await command.handler("", f.ctx); + assert.equal((await saved()).logo, "original"); + await command.handler(" ", f.ctx); + assert.equal((await saved()).logo, "minimal"); + await command.handler("original", f.ctx); + assert.equal((await saved()).logo, "original"); + await command.handler("minimal", f.ctx); + assert.equal((await saved()).logo, "minimal"); + await command.handler("retro", f.ctx); + assert.equal((await saved()).logo, "minimal"); + assert.ok(f.notices.includes("Usage: /logo [original|minimal]")); +}); + test("a picker choice is persisted as the saved theme", async t => { const f = await fixture(t, JSON.stringify({ surfaces: ["sidebar"] })); const settingsPath = join(dirname(f.path), "settings.json"); diff --git a/pi-slate/tests/updates.test.ts b/pi-slate/tests/updates.test.ts deleted file mode 100644 index 894a324..0000000 --- a/pi-slate/tests/updates.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { formatUpdateNotice, isNewerVersion } from "../extensions/pi-slate/updates.ts"; - -test("formats none, one, many, pi, and mixed notices", () => { - assert.equal(formatUpdateNotice({ packages: [] }), undefined); - assert.equal( - formatUpdateNotice({ packages: ["@dev.fast/pi-whiteboard"] }), - "update available · @dev.fast/pi-whiteboard · pi update --extensions", - ); - assert.equal( - formatUpdateNotice({ packages: ["pi-whiteboard", "pi-ask", "pi-subagents"] }), - "3 updates available · pi-whiteboard, pi-ask, pi-subagents · pi update --extensions", - ); - assert.equal( - formatUpdateNotice({ pi: { current: "0.99.1", next: "0.99.2" }, packages: [] }), - "update available · pi 0.99.1 → 0.99.2 · pi update", - ); - assert.equal( - formatUpdateNotice({ - pi: { current: "0.99.1", next: "0.99.2" }, - packages: ["@dev.fast/pi-whiteboard"], - }), - "2 updates available · pi 0.99.1 → 0.99.2, @dev.fast/pi-whiteboard · pi update", - ); -}); - -test("compares dotted versions", () => { - assert.equal(isNewerVersion("0.99.2", "0.99.1"), true); - assert.equal(isNewerVersion("0.99.1", "0.99.1"), false); - assert.equal(isNewerVersion("0.98.9", "0.99.1"), false); -});