diff --git a/.gitignore b/.gitignore index 1018345..c6faf08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.worktrees/ # Temp files from runInlineTs (tests/e2e). Cleaned up by a finally block # on success; listing them here keeps a SIGKILL'd test runner from leaving diff --git a/docs/cvd-palette-preview.html b/docs/cvd-palette-preview.html new file mode 100644 index 0000000..5efdcec --- /dev/null +++ b/docs/cvd-palette-preview.html @@ -0,0 +1,187 @@ + + + + + asm CVD palette preview + + + + + + + + + + + + + + +
+

asm CVD palette — Okabe–Ito default

+
+ + + + +
+ Must-have screens. Color is never the only signal. +
+
+
+
Roles
+
+
accent #56B4E9
+
success #2BC48A
+
warning #E69F00
+
danger #D55E00
+
special #CC79A7
+
fg #E8E8E8
+
dim #9A9A9A
+
+
+ +
+
TUI dashboard (rainbow stripped; dupes dual-encoded)
+
+agent-skill-manager +
Total: 42 (38 unique) Global: 30 Project: 12 Symlinks: 4 Tools: 6 Dupes: ! 3
+ Both Global Project — Claude, Codex +4 +
press / to search...
+❯ skill-auto-improver 0.3.1 medium Claude Code + skill-creator 1.2.0 low Codex + beads 1.4.0 high Grok CLI +
+
+ +
+
TUI config (ASCII on/off)
+
+
Configuration
+ Config: ~/.config/agent-skill-manager/config.json + Tools (Enter to toggle, e to edit config file): +❯ on Claude Code ~/.claude/skills + on Codex ~/.codex/skills + off Grok CLI ~/.grok/skills +
+
+ +
+
TUI uninstall confirm
+
+
Uninstall: old-skill
+ The following will be removed: + ~/.claude/skills/old-skill + Yes, uninstall Cancel +
+
+ +
+
CLI asm list (effort words + hue; no provider hues)
+
+Name Version Effort Tool +skill-auto-improver 0.3.1 medium [Claude Code] +skill-creator 1.2.0 low [Codex] +beads 1.4.0 high [Grok CLI] +eval-max 0.1.0 max [Oh My Pi] +
+
+ +
+
CLI tools + security badges (no bg fills)
+
+Allowed tools: Bash ! Read WebFetch + + +-- Security Audit --------------------------+ + | clean-skill [SAFE]| + | risky-skill [!! DANGEROUS]| + | noisy-skill [WARNING]| + | odd-skill [CAUTION]| +
+
+
+ + + diff --git a/src/formatter-core.ts b/src/formatter-core.ts index 8cd1528..09f1302 100644 --- a/src/formatter-core.ts +++ b/src/formatter-core.ts @@ -7,38 +7,9 @@ import type { SkillInfo } from "./utils/types"; import { formatTokenCount } from "./utils/token-count"; import { formatInvocability } from "./utils/frontmatter"; +import { ansi, useColor } from "./utils/colors"; -// ─── Color helpers ────────────────────────────────────────────────────────── - -export const useColor = (): boolean => { - if (process.env.NO_COLOR !== undefined) return false; - if (globalThis.__CLI_NO_COLOR) return false; - if (!process.stdout.isTTY) return false; - return true; -}; - -const ansi = { - bold: (s: string) => (useColor() ? `\x1b[1m${s}\x1b[0m` : s), - cyan: (s: string) => (useColor() ? `\x1b[36m${s}\x1b[0m` : s), - green: (s: string) => (useColor() ? `\x1b[32m${s}\x1b[0m` : s), - yellow: (s: string) => (useColor() ? `\x1b[33m${s}\x1b[0m` : s), - dim: (s: string) => (useColor() ? `\x1b[2m${s}\x1b[0m` : s), - white: (s: string) => (useColor() ? `\x1b[37m${s}\x1b[0m` : s), - red: (s: string) => (useColor() ? `\x1b[31m${s}\x1b[0m` : s), - blue: (s: string) => (useColor() ? `\x1b[34m${s}\x1b[0m` : s), - blueBold: (s: string) => (useColor() ? `\x1b[34;1m${s}\x1b[0m` : s), - magenta: (s: string) => (useColor() ? `\x1b[35m${s}\x1b[0m` : s), - bgDim: (s: string) => (useColor() ? `\x1b[48;5;236m${s}\x1b[0m` : s), - bgRed: (s: string) => (useColor() ? `\x1b[41m\x1b[37m\x1b[1m${s}\x1b[0m` : s), - bgYellow: (s: string) => - useColor() ? `\x1b[43m\x1b[30m\x1b[1m${s}\x1b[0m` : s, - bgGreen: (s: string) => - useColor() ? `\x1b[42m\x1b[30m\x1b[1m${s}\x1b[0m` : s, - bgCyan: (s: string) => - useColor() ? `\x1b[46m\x1b[30m\x1b[1m${s}\x1b[0m` : s, -}; - -export { ansi }; +export { ansi, useColor }; // ─── Effort colors ───────────────────────────────────────────────────────── @@ -58,37 +29,13 @@ export function colorEffort(effort: string | undefined): string { } } -// ─── Provider colors ─────────────────────────────────────────────────────── - -const PROVIDER_COLORS: Record string> = { - claude: ansi.blueBold, - codex: ansi.cyan, - "codex-plugin": ansi.cyan, - openclaw: ansi.yellow, - agents: ansi.green, - custom: ansi.magenta, - cursor: ansi.blue, - windsurf: ansi.cyan, - cline: ansi.green, - roocode: ansi.magenta, - continue: ansi.yellow, - copilot: ansi.white, - aider: ansi.red, - opencode: ansi.cyan, - zed: ansi.blue, - augment: ansi.green, - amp: ansi.yellow, -}; - -export function colorProvider(provider: string, label: string): string { - const colorFn = PROVIDER_COLORS[provider] || ansi.dim; - return colorFn(label); +// Provider names are the signal; hue is not used. +export function colorProvider(_provider: string, label: string): string { + return label; } -export function providerBadge(provider: string, label: string): string { - if (!useColor()) return `[${label}]`; - const colorFn = PROVIDER_COLORS[provider] || ansi.dim; - return colorFn(`[${label}]`); +export function providerBadge(_provider: string, label: string): string { + return `[${label}]`; } // ─── Path shortening ─────────────────────────────────────────────────────── @@ -728,7 +675,7 @@ export const HIGH_RISK_TOOLS = new Set([ export const MEDIUM_RISK_TOOLS = new Set(["WebFetch", "WebSearch"]); export function colorTool(tool: string): string { - if (HIGH_RISK_TOOLS.has(tool)) return ansi.red(tool); + if (HIGH_RISK_TOOLS.has(tool)) return ansi.red(`${tool} !`); if (MEDIUM_RISK_TOOLS.has(tool)) return ansi.yellow(tool); return ansi.green(tool); } diff --git a/src/formatter.test.ts b/src/formatter.test.ts index 2c8def0..7609565 100644 --- a/src/formatter.test.ts +++ b/src/formatter.test.ts @@ -527,6 +527,28 @@ describe("colorProvider", () => { }); }); +describe("colorProvider drops per-provider hues", () => { + test("does not wrap labels in ANSI when color is on", () => { + delete (globalThis as { __CLI_NO_COLOR?: boolean }).__CLI_NO_COLOR; + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + process.env.COLORTERM = "truecolor"; + try { + expect(colorProvider("claude", "Claude Code")).toBe("Claude Code"); + expect(colorProvider("codex", "Codex")).toBe("Codex"); + } finally { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + delete process.env.COLORTERM; + } + }); +}); + // ─── formatGroupedTable ─────────────────────────────────────────────────── describe("formatGroupedTable", () => { @@ -851,10 +873,10 @@ describe("colorTool", () => { }); test("returns tool name for high-risk tools (no ANSI in no-color mode)", () => { - expect(colorTool("Bash")).toBe("Bash"); - expect(colorTool("Write")).toBe("Write"); - expect(colorTool("Edit")).toBe("Edit"); - expect(colorTool("NotebookEdit")).toBe("NotebookEdit"); + expect(colorTool("Bash")).toBe("Bash !"); + expect(colorTool("Write")).toBe("Write !"); + expect(colorTool("Edit")).toBe("Edit !"); + expect(colorTool("NotebookEdit")).toBe("NotebookEdit !"); }); test("returns tool name for medium-risk tools", () => { @@ -897,7 +919,7 @@ describe("formatAllowedTools", () => { test("joins tools with double-space separator", () => { const result = formatAllowedTools(["Bash", "Read", "Grep"]); - expect(result).toBe("Bash Read Grep"); + expect(result).toBe("Bash ! Read Grep"); }); test("handles single tool", () => { diff --git a/src/index.tsx b/src/index.tsx index 3c5bc34..1347f3a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -334,7 +334,12 @@ export function App({ initialConfig }: AppProps) { const visibleListRows = Math.max(5, termHeight - chromeRows - 4); return ( - + {view === "dashboard" && ( <> {scanning && !hasScanned && ( diff --git a/src/security-auditor.test.ts b/src/security-auditor.test.ts index 22fa11a..f51b05a 100644 --- a/src/security-auditor.test.ts +++ b/src/security-auditor.test.ts @@ -792,7 +792,8 @@ describe("formatSecurityReport", () => { expect(output).toContain("Security Audit"); expect(output).toContain("clean"); - expect(output).toContain("SAFE"); + expect(output).toContain("[SAFE]"); + expect(output).not.toContain(" SAFE "); expect(output).toContain("No suspicious patterns"); }); diff --git a/src/security-auditor.ts b/src/security-auditor.ts index bbd398c..17529c2 100644 --- a/src/security-auditor.ts +++ b/src/security-auditor.ts @@ -651,13 +651,13 @@ const SEVERITY_ORDER: Record = { function verdictBadge(verdict: SecurityVerdict): string { switch (verdict) { case "safe": - return color.bgGreen(" SAFE "); + return color.green("[SAFE]"); case "caution": - return color.bgCyan(" CAUTION "); + return color.cyan("[CAUTION]"); case "warning": - return color.bgYellow(" WARNING "); + return color.yellow("[WARNING]"); case "dangerous": - return color.bgRed(" DANGEROUS "); + return color.red("[!! DANGEROUS]"); } } diff --git a/src/utils/colors.test.ts b/src/utils/colors.test.ts new file mode 100644 index 0000000..7ad1bae --- /dev/null +++ b/src/utils/colors.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { ansi, paint, roles, useColor, useTrueColor } from "./colors"; + +const originalIsTTY = process.stdout.isTTY; +const originalColorTerm = process.env.COLORTERM; +const originalNoColor = process.env.NO_COLOR; + +function enableColor(truecolor: boolean): void { + delete process.env.NO_COLOR; + delete (globalThis as { __CLI_NO_COLOR?: boolean }).__CLI_NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + if (truecolor) process.env.COLORTERM = "truecolor"; + else delete process.env.COLORTERM; +} + +afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + if (originalColorTerm === undefined) delete process.env.COLORTERM; + else process.env.COLORTERM = originalColorTerm; + if (originalNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = originalNoColor; + delete (globalThis as { __CLI_NO_COLOR?: boolean }).__CLI_NO_COLOR; +}); + +describe("useColor / useTrueColor", () => { + test("NO_COLOR disables color even on a TTY", () => { + enableColor(true); + process.env.NO_COLOR = ""; + expect(useColor()).toBe(false); + }); + + test("__CLI_NO_COLOR disables color", () => { + enableColor(true); + globalThis.__CLI_NO_COLOR = true; + expect(useColor()).toBe(false); + }); + + test("COLORTERM=truecolor enables 24-bit", () => { + enableColor(true); + expect(useTrueColor()).toBe(true); + }); + + test("COLORTERM=24bit enables 24-bit", () => { + enableColor(false); + process.env.COLORTERM = "24bit"; + expect(useTrueColor()).toBe(true); + }); + + test("missing COLORTERM is 16-color fallback", () => { + enableColor(false); + expect(useTrueColor()).toBe(false); + }); +}); + +describe("paint", () => { + test("returns the string unchanged when color is off", () => { + globalThis.__CLI_NO_COLOR = true; + expect(paint("danger", "x")).toBe("x"); + expect(ansi.red("x")).toBe("x"); + }); + + test("truecolor uses 24-bit SGR from the role hex", () => { + enableColor(true); + expect(paint("accent", "hi")).toBe("\x1b[38;2;86;180;233mhi\x1b[0m"); + expect(paint("success", "hi")).toBe("\x1b[38;2;43;196;138mhi\x1b[0m"); + expect(paint("warning", "hi")).toBe("\x1b[38;2;230;159;0mhi\x1b[0m"); + expect(paint("danger", "hi")).toBe("\x1b[38;2;213;94;0mhi\x1b[0m"); + expect(paint("special", "hi")).toBe("\x1b[38;2;204;121;167mhi\x1b[0m"); + }); + + test("16-color fallback uses the locked SGR map", () => { + enableColor(false); + expect(paint("accent", "hi")).toBe("\x1b[36mhi\x1b[0m"); + expect(paint("success", "hi")).toBe("\x1b[32mhi\x1b[0m"); + expect(paint("warning", "hi")).toBe("\x1b[33mhi\x1b[0m"); + expect(paint("danger", "hi")).toBe("\x1b[31mhi\x1b[0m"); + expect(paint("special", "hi")).toBe("\x1b[35mhi\x1b[0m"); + }); + + test("role hex values match the locked Q10 map", () => { + expect(roles.accent).toBe("#56B4E9"); + expect(roles.success).toBe("#2BC48A"); + expect(roles.warning).toBe("#E69F00"); + expect(roles.danger).toBe("#D55E00"); + expect(roles.special).toBe("#CC79A7"); + expect(roles.fg).toBe("#E8E8E8"); + expect(roles.dim).toBe("#9A9A9A"); + expect(roles.border).toBe("#6B6B6B"); + }); +}); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 3c04b53..e7eb2a0 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -1,16 +1,123 @@ +/** + * Shared semantic palette for CLI (ANSI) and TUI (Ink hex). + * + * Default is a dark-tuned Okabe–Ito set. Status is dual-encoded in callers + * (words/glyphs plus hue). --no-color / NO_COLOR still strip all SGR. + */ + +export type ColorRole = + | "fg" + | "dim" + | "border" + | "accent" + | "success" + | "warning" + | "danger" + | "special"; + +/** Dark-tuned Okabe–Ito default. */ +export const roles: Record = { + fg: "#E8E8E8", + dim: "#9A9A9A", + border: "#6B6B6B", + accent: "#56B4E9", + success: "#2BC48A", + warning: "#E69F00", + danger: "#D55E00", + special: "#CC79A7", +}; + +/** 16-color SGR foreground codes when COLORTERM is not truecolor/24bit. */ +const ANSI16: Record = { + fg: 37, + dim: 90, + border: 90, + accent: 36, + success: 32, + warning: 33, + danger: 31, + special: 35, +}; + +export function useColor(): boolean { + if (process.env.NO_COLOR !== undefined) return false; + if (globalThis.__CLI_NO_COLOR) return false; + if (!process.stdout.isTTY) return false; + return true; +} + +export function useTrueColor(): boolean { + const ct = (process.env.COLORTERM ?? "").toLowerCase(); + return ct === "truecolor" || ct === "24bit"; +} + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.slice(1); + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; +} + +export function paint(role: ColorRole, s: string): string { + if (!useColor()) return s; + if (useTrueColor()) { + const [r, g, b] = hexToRgb(roles[role]); + return `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`; + } + return `\x1b[${ANSI16[role]}m${s}\x1b[0m`; +} + +export const ansi = { + bold: (s: string) => (useColor() ? `\x1b[1m${s}\x1b[0m` : s), + cyan: (s: string) => paint("accent", s), + green: (s: string) => paint("success", s), + yellow: (s: string) => paint("warning", s), + dim: (s: string) => paint("dim", s), + white: (s: string) => paint("fg", s), + red: (s: string) => paint("danger", s), + blue: (s: string) => paint("accent", s), + blueBold: (s: string) => { + if (!useColor()) return s; + if (useTrueColor()) { + const [r, g, b] = hexToRgb(roles.accent); + return `\x1b[1;38;2;${r};${g};${b}m${s}\x1b[0m`; + } + return `\x1b[36;1m${s}\x1b[0m`; + }, + magenta: (s: string) => paint("special", s), + bgDim: (s: string) => (useColor() ? `\x1b[48;5;236m${s}\x1b[0m` : s), + bgRed: (s: string) => + useColor() ? `\x1b[41m\x1b[37m\x1b[1m${s}\x1b[0m` : s, + bgYellow: (s: string) => + useColor() ? `\x1b[43m\x1b[30m\x1b[1m${s}\x1b[0m` : s, + bgGreen: (s: string) => + useColor() ? `\x1b[42m\x1b[30m\x1b[1m${s}\x1b[0m` : s, + bgCyan: (s: string) => + useColor() ? `\x1b[46m\x1b[30m\x1b[1m${s}\x1b[0m` : s, +}; + +/** Ink TUI tokens. Legacy keys alias the Okabe–Ito roles. */ export const theme = { bg: "#1a1b26", bgAlt: "#24283b", - fg: "#c0caf5", - fgDim: "#565f89", - accent: "#7aa2f7", - accentAlt: "#bb9af7", - green: "#9ece6a", - red: "#f7768e", - yellow: "#e0af68", - cyan: "#7dcfff", - orange: "#ff9e64", - border: "#3b4261", - borderFocus: "#7aa2f7", + fg: roles.fg, + fgDim: roles.dim, + accent: roles.accent, + accentAlt: roles.special, + green: roles.success, + red: roles.danger, + yellow: roles.warning, + cyan: roles.accent, + orange: roles.warning, + border: roles.border, + borderFocus: roles.accent, white: "#FFFFFF", } as const; + +/** Selected-row style: muted navy wash, light text. Never inverse, never a neon bar. */ +export const selectedFill = { + color: theme.fg, + backgroundColor: theme.bgAlt, +} as const; diff --git a/src/views/config.test.tsx b/src/views/config.test.tsx index 1035165..a1c5d39 100644 --- a/src/views/config.test.tsx +++ b/src/views/config.test.tsx @@ -49,8 +49,8 @@ describe("ConfigView", () => { expect(frame).toContain("Tools (Enter to toggle, e to edit config file):"); expect(frame).toContain("Enter Toggle e Edit file Esc Save & close"); // First provider enabled, second disabled. - expect(frame).toContain("✔ ON"); - expect(frame).toContain("✘ OFF"); + expect(frame).toContain("on "); + expect(frame).toContain("off"); }); it("shows the project path for the selected (first) row", () => { diff --git a/src/views/config.tsx b/src/views/config.tsx index 08a57f4..4bfbc2a 100644 --- a/src/views/config.tsx +++ b/src/views/config.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { Box, Text, useInput } from "ink"; -import { theme } from "../utils/colors"; +import { selectedFill, theme } from "../utils/colors"; import { getConfigPath } from "../config"; import type { AppConfig } from "../utils/types"; @@ -9,7 +9,7 @@ function providerRow( globalPath: string, enabled: boolean, ): string { - const status = enabled ? "✔ ON " : "✘ OFF"; + const status = enabled ? "on " : "off"; const name = label.length > 14 ? label.slice(0, 14) : label; return `${status} ${name.padEnd(15)} ${globalPath}`; } @@ -88,11 +88,14 @@ export function ConfigView({ config, onClose, onOpenEditor }: ConfigProps) { return ( - {isSelected ? "❯ " : " "} - {row} + + {isSelected ? "❯ " : " "} + + {row} {isSelected && ( Project: {p.project} diff --git a/src/views/dashboard.test.tsx b/src/views/dashboard.test.tsx index 51194a5..db7f6ad 100644 --- a/src/views/dashboard.test.tsx +++ b/src/views/dashboard.test.tsx @@ -97,9 +97,8 @@ describe("DashboardHeader", () => { expect(frame).toContain("Symlinks:"); expect(frame).toContain("Tools:"); expect(frame).toContain("Dupes:"); - // The values column-group renders as "2 1 1 2 1" after the labels (global - // 2, project 1, symlinks 1, tools 2, dupes 1). - expect(frame).toContain("2 1 1 2 1"); + // Ink wraps the stats row; dual-encoded dupes land in the values group. + expect(frame).toContain("2 1 1 2 ! 1"); }); it("renders the sort label with the active sort bracketed", () => { @@ -120,6 +119,7 @@ describe("DashboardHeader", () => { // buildSortLabel: "(s) Sort: name [version] location" expect(frame).toContain("[version]"); expect(frame).toContain("Sort:"); + expect(frame).not.toContain("!"); }); it("highlights the active scope tab and dims the others", () => { diff --git a/src/views/dashboard.tsx b/src/views/dashboard.tsx index 20846da..1a671b0 100644 --- a/src/views/dashboard.tsx +++ b/src/views/dashboard.tsx @@ -54,8 +54,9 @@ export function DashboardHeader({ - agent-skill-manager + asm + agent-skill-manager Total: {total} ({unique} unique) - Global: {globalCount} + Global: {globalCount} Project: {projectCount} Symlinks: {symlinks} Tools: {providers} - Dupes: {duplicateCount} + 0 ? theme.orange : theme.fgDim}> + Dupes: {duplicateCount > 0 ? `!\u00a0${duplicateCount}` : duplicateCount} + {buildSortLabel(sort)} @@ -110,13 +113,10 @@ export function DashboardHeader({ function ScopeTab({ label, active }: { label: string; active: boolean }) { return ( - - {" "} - {label}{" "} + + {active ? "[" : " "} + {label} + {active ? "]" : " "} ); } diff --git a/src/views/duplicates.tsx b/src/views/duplicates.tsx index 64e67d4..e368a7c 100644 --- a/src/views/duplicates.tsx +++ b/src/views/duplicates.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { Box, Text, useInput } from "ink"; -import { theme } from "../utils/colors"; +import { selectedFill, theme } from "../utils/colors"; import { sortInstancesForKeep, reasonLabel } from "../auditor"; import type { AuditReport, DuplicateGroup, SkillInfo } from "../utils/types"; @@ -165,8 +165,10 @@ function GroupsPhase({ return ( {isSelected ? "❯ " : " "} {label} @@ -207,8 +209,10 @@ function InstancesPhase({ return ( {isSelected ? "❯ " : " "} {checked} {s.providerLabel}/{s.scope} - {s.path} @@ -217,8 +221,10 @@ function InstancesPhase({ ); })} {cursor === sorted.length ? "❯ " : " "} {actionLabel} diff --git a/src/views/skill-list.tsx b/src/views/skill-list.tsx index d20d3b6..0cd4db5 100644 --- a/src/views/skill-list.tsx +++ b/src/views/skill-list.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import { theme } from "../utils/colors"; +import { selectedFill, theme } from "../utils/colors"; import type { SkillInfo } from "../utils/types"; import { formatTokenCount } from "../utils/token-count"; import { formatInvocability } from "../utils/frontmatter"; @@ -27,7 +27,7 @@ function formatSkillRow( index: number, skill: SkillInfo, descWidth: number, -): string { +): { before: string; effortCell: string; after: string; full: string } { const idx = String(index).padStart(3); const prefix = skill.isSymlink ? "~ " : " "; const nameMax = 24 - prefix.length; @@ -53,7 +53,30 @@ function formatSkillRow( const type = skill.isSymlink ? "→link" : " dir "; const desc = descWidth > 0 ? " " + (skill.description || "").slice(0, descWidth) : ""; - return `${idx} ${name.padEnd(24)} ${ver.padEnd(8)} ${creator.padEnd(11)} ${effort.padEnd(7)} ${invoke.padEnd(6)} ${tokens.padEnd(6)} ${prov.padEnd(12)} ${scope.padEnd(7)} ${type.padEnd(5)}${desc}`; + const before = `${idx} ${name.padEnd(24)} ${ver.padEnd(8)} ${creator.padEnd(11)} `; + const effortCell = effort.padEnd(7); + const after = ` ${invoke.padEnd(6)} ${tokens.padEnd(6)} ${prov.padEnd(12)} ${scope.padEnd(7)} ${type.padEnd(5)}${desc}`; + return { + before, + effortCell, + after, + full: `${before}${effortCell}${after}`, + }; +} + +function effortColor(effort: string | undefined): string { + switch ((effort || "").toLowerCase()) { + case "low": + return theme.green; + case "medium": + return theme.yellow; + case "high": + return theme.red; + case "max": + return theme.accentAlt; + default: + return theme.fgDim; + } } export interface SkillListProps { @@ -112,11 +135,14 @@ export function SkillListView({ return ( - {prefix} - {row} + {prefix} + {row.before} + {row.effortCell} + {row.after} ); })}