- Sync all packages to version 0.7.7 - Rewrite sync-versions.js to handle ALL inter-package dependencies automatically - Fix web-ui dependency on pi-ai (was 0.6.0, now 0.7.7) - Move agent fix changelog entry to coding-agent CHANGELOG - Remove redundant agent CHANGELOG.md - Improve README.md with clearer lockstep versioning docs - Add /changelog command to display full changelog in TUI (newest last) - Fix changelog description (not a scrollable viewer, just displays in chat) - Update CHANGELOG for 0.7.7 release
24 KiB
Design Tokens System
Overview
A minimal design tokens system for terminal UI theming. Uses a two-layer approach:
- Primitive tokens - Raw color values
- Semantic tokens - Purpose-based mappings that reference primitives
Architecture
Primitive Tokens (Colors)
These are the raw chalk color functions - the "palette":
interface ColorPrimitives {
// Grays
gray50: ChalkFunction; // Lightest gray
gray100: ChalkFunction;
gray200: ChalkFunction;
gray300: ChalkFunction;
gray400: ChalkFunction;
gray500: ChalkFunction; // Mid gray
gray600: ChalkFunction;
gray700: ChalkFunction;
gray800: ChalkFunction;
gray900: ChalkFunction; // Darkest gray
// Colors
blue: ChalkFunction;
cyan: ChalkFunction;
green: ChalkFunction;
yellow: ChalkFunction;
red: ChalkFunction;
magenta: ChalkFunction;
// Modifiers
bold: ChalkFunction;
dim: ChalkFunction;
italic: ChalkFunction;
underline: ChalkFunction;
strikethrough: ChalkFunction;
// Special
none: ChalkFunction; // Pass-through, no styling
}
type ChalkFunction = (str: string) => string;
Semantic Tokens (Design Decisions)
These map primitives to purposes:
interface SemanticTokens {
// Text hierarchy
text: {
primary: ChalkFunction; // Main content text
secondary: ChalkFunction; // Supporting text
tertiary: ChalkFunction; // De-emphasized text
disabled: ChalkFunction; // Inactive/disabled text
};
// Interactive elements
interactive: {
default: ChalkFunction; // Default interactive elements
hover: ChalkFunction; // Hovered/selected state
active: ChalkFunction; // Active/current state
};
// Feedback
feedback: {
error: ChalkFunction;
warning: ChalkFunction;
success: ChalkFunction;
info: ChalkFunction;
};
// Borders & dividers
border: {
default: ChalkFunction;
subtle: ChalkFunction;
emphasis: ChalkFunction;
};
// Code
code: {
text: ChalkFunction;
keyword: ChalkFunction;
string: ChalkFunction;
comment: ChalkFunction;
delimiter: ChalkFunction;
};
// Markdown specific
markdown: {
heading: {
h1: ChalkFunction;
h2: ChalkFunction;
h3: ChalkFunction;
};
emphasis: {
bold: ChalkFunction;
italic: ChalkFunction;
strikethrough: ChalkFunction;
};
link: {
text: ChalkFunction;
url: ChalkFunction;
};
quote: {
text: ChalkFunction;
border: ChalkFunction;
};
list: {
bullet: ChalkFunction;
};
code: {
inline: ChalkFunction;
inlineDelimiter: ChalkFunction;
block: ChalkFunction;
blockDelimiter: ChalkFunction;
};
};
// Output streams
output: {
stdout: ChalkFunction;
stderr: ChalkFunction;
neutral: ChalkFunction;
};
}
Theme Structure
A theme combines primitives with semantic mappings:
interface Theme {
name: string;
primitives: ColorPrimitives;
tokens: SemanticTokens;
}
Built-in Themes
Dark Theme
const darkPrimitives: ColorPrimitives = {
// Grays - for dark backgrounds, lighter = more prominent
gray50: chalk.white,
gray100: (s) => s, // No color = terminal default
gray200: chalk.white,
gray300: (s) => s,
gray400: chalk.gray,
gray500: chalk.gray,
gray600: chalk.gray,
gray700: chalk.dim,
gray800: chalk.dim,
gray900: chalk.black,
// Colors
blue: chalk.blue,
cyan: chalk.cyan,
green: chalk.green,
yellow: chalk.yellow,
red: chalk.red,
magenta: chalk.magenta,
// Modifiers
bold: chalk.bold,
dim: chalk.dim,
italic: chalk.italic,
underline: chalk.underline,
strikethrough: chalk.strikethrough,
// Special
none: (s) => s,
};
const darkTheme: Theme = {
name: "dark",
primitives: darkPrimitives,
tokens: {
text: {
primary: darkPrimitives.gray100,
secondary: darkPrimitives.gray400,
tertiary: darkPrimitives.gray700,
disabled: darkPrimitives.dim,
},
interactive: {
default: darkPrimitives.blue,
hover: darkPrimitives.blue,
active: (s) => darkPrimitives.bold(darkPrimitives.blue(s)),
},
feedback: {
error: darkPrimitives.red,
warning: darkPrimitives.yellow,
success: darkPrimitives.green,
info: darkPrimitives.cyan,
},
border: {
default: darkPrimitives.blue,
subtle: darkPrimitives.gray600,
emphasis: darkPrimitives.cyan,
},
code: {
text: darkPrimitives.green,
keyword: darkPrimitives.cyan,
string: darkPrimitives.green,
comment: darkPrimitives.gray600,
delimiter: darkPrimitives.gray600,
},
markdown: {
heading: {
h1: (s) => darkPrimitives.underline(darkPrimitives.bold(darkPrimitives.yellow(s))),
h2: (s) => darkPrimitives.bold(darkPrimitives.yellow(s)),
h3: darkPrimitives.bold,
},
emphasis: {
bold: darkPrimitives.bold,
italic: darkPrimitives.italic,
strikethrough: darkPrimitives.strikethrough,
},
link: {
text: (s) => darkPrimitives.underline(darkPrimitives.blue(s)),
url: darkPrimitives.gray600,
},
quote: {
text: darkPrimitives.italic,
border: darkPrimitives.gray600,
},
list: {
bullet: darkPrimitives.cyan,
},
code: {
inline: darkPrimitives.cyan,
inlineDelimiter: darkPrimitives.gray600,
block: darkPrimitives.green,
blockDelimiter: darkPrimitives.gray600,
},
},
output: {
stdout: darkPrimitives.green,
stderr: darkPrimitives.red,
neutral: darkPrimitives.gray600,
},
},
};
Light Theme
const lightPrimitives: ColorPrimitives = {
// Grays - for light backgrounds, darker = more prominent
gray50: chalk.black,
gray100: (s) => s, // No color = terminal default
gray200: chalk.black,
gray300: (s) => s,
gray400: chalk.gray, // Use actual gray, not dim
gray500: chalk.gray,
gray600: chalk.gray,
gray700: chalk.gray,
gray800: chalk.gray,
gray900: chalk.white,
// Colors - use bold variants for better visibility on light bg
blue: (s) => chalk.bold(chalk.blue(s)),
cyan: (s) => chalk.bold(chalk.cyan(s)),
green: (s) => chalk.bold(chalk.green(s)),
yellow: (s) => chalk.bold(chalk.yellow(s)),
red: (s) => chalk.bold(chalk.red(s)),
magenta: (s) => chalk.bold(chalk.magenta(s)),
// Modifiers
bold: chalk.bold,
dim: chalk.gray, // Don't use chalk.dim on light bg!
italic: chalk.italic,
underline: chalk.underline,
strikethrough: chalk.strikethrough,
// Special
none: (s) => s,
};
const lightTheme: Theme = {
name: "light",
primitives: lightPrimitives,
tokens: {
text: {
primary: lightPrimitives.gray100,
secondary: lightPrimitives.gray400,
tertiary: lightPrimitives.gray600,
disabled: lightPrimitives.dim,
},
interactive: {
default: lightPrimitives.blue,
hover: lightPrimitives.blue,
active: (s) => lightPrimitives.bold(lightPrimitives.blue(s)),
},
feedback: {
error: lightPrimitives.red,
warning: (s) => chalk.bold(chalk.yellow(s)), // Yellow needs extra bold
success: lightPrimitives.green,
info: lightPrimitives.cyan,
},
border: {
default: lightPrimitives.blue,
subtle: lightPrimitives.gray400,
emphasis: lightPrimitives.cyan,
},
code: {
text: lightPrimitives.green,
keyword: lightPrimitives.cyan,
string: lightPrimitives.green,
comment: lightPrimitives.gray600,
delimiter: lightPrimitives.gray600,
},
markdown: {
heading: {
h1: (s) => lightPrimitives.underline(lightPrimitives.bold(lightPrimitives.blue(s))),
h2: (s) => lightPrimitives.bold(lightPrimitives.blue(s)),
h3: lightPrimitives.bold,
},
emphasis: {
bold: lightPrimitives.bold,
italic: lightPrimitives.italic,
strikethrough: lightPrimitives.strikethrough,
},
link: {
text: (s) => lightPrimitives.underline(lightPrimitives.blue(s)),
url: lightPrimitives.blue,
},
quote: {
text: lightPrimitives.italic,
border: lightPrimitives.gray600,
},
list: {
bullet: lightPrimitives.blue,
},
code: {
inline: lightPrimitives.blue,
inlineDelimiter: lightPrimitives.gray600,
block: lightPrimitives.green,
blockDelimiter: lightPrimitives.gray600,
},
},
output: {
stdout: lightPrimitives.green,
stderr: lightPrimitives.red,
neutral: lightPrimitives.gray600,
},
},
};
Usage Examples
Simple Text Styling
const theme = getTheme();
// Before
console.log(chalk.gray("Secondary text"));
// After
console.log(theme.tokens.text.secondary("Secondary text"));
Interactive Elements
const theme = getTheme();
// Before
const cursor = chalk.blue("› ");
// After
const cursor = theme.tokens.interactive.default("› ");
Error Messages
const theme = getTheme();
// Before
this.contentContainer.addChild(new Text(chalk.red("Error: " + errorMsg)));
// After
this.contentContainer.addChild(new Text(theme.tokens.feedback.error("Error: " + errorMsg)));
Markdown Headings
const theme = getTheme();
// Before
lines.push(chalk.bold.yellow(headingText));
// After
lines.push(theme.tokens.markdown.heading.h2(headingText));
Borders
const theme = getTheme();
// Before
this.addChild(new Text(chalk.blue("─".repeat(80))));
// After
this.addChild(new Text(theme.tokens.border.default("─".repeat(80))));
User Configuration
Theme File Format
Themes can be defined in JSON files that users can customize. The system will load themes from:
- Built-in themes (dark, light) - hardcoded in the app
- User themes in
~/.pi/agent/themes/directory
Example: ~/.pi/agent/themes/my-theme.json
{
"name": "my-theme",
"extends": "dark",
"primitives": {
"blue": "blueBright",
"cyan": "cyanBright",
"green": "greenBright"
},
"tokens": {
"text": {
"primary": "white"
},
"interactive": {
"default": ["bold", "blue"]
},
"markdown": {
"heading": {
"h1": ["bold", "underline", "magenta"],
"h2": ["bold", "magenta"]
}
}
}
}
JSON Schema
Themes in JSON can reference:
- Chalk color names:
"red","blue","gray","white","black", etc. - Chalk bright colors:
"redBright","blueBright", etc. - Chalk modifiers:
"bold","dim","italic","underline","strikethrough" - Combinations:
["bold", "blue"]or["underline", "bold", "cyan"] - Primitive references:
"$gray400"to reference another primitive - None/passthrough:
"none"or""for no styling
Supported Chalk Values
type ChalkColorName =
// Basic colors
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray"
// Bright variants
| "blackBright" | "redBright" | "greenBright" | "yellowBright"
| "blueBright" | "magentaBright" | "cyanBright" | "whiteBright"
// Modifiers
| "bold" | "dim" | "italic" | "underline" | "strikethrough" | "inverse"
// Special
| "none";
type ChalkValue = ChalkColorName | ChalkColorName[] | string; // string allows "$primitive" refs
Theme Extension
Themes can extend other themes using "extends": "dark" or "extends": "light". Only the overridden values need to be specified.
Example: Minimal override
{
"name": "solarized-dark",
"extends": "dark",
"tokens": {
"feedback": {
"error": "magenta",
"warning": "yellow"
},
"markdown": {
"heading": {
"h1": ["bold", "cyan"],
"h2": ["bold", "blue"]
}
}
}
}
Loading Order
- Load built-in themes (dark, light)
- Scan
~/.pi/agent/themes/*.json - Parse and validate each JSON theme
- Build theme by:
- Start with base theme (if extends specified)
- Apply primitive overrides
- Apply token overrides
- Convert JSON values to chalk functions
Implementation
Theme Module Structure
Location: packages/tui/src/theme/
theme/
├── index.ts # Public API
├── types.ts # Type definitions
├── primitives.ts # Color primitives for each theme
├── tokens.ts # Semantic token mappings
├── themes.ts # Built-in theme definitions
├── registry.ts # Theme management (current, set, get)
├── loader.ts # JSON theme loader
└── parser.ts # JSON to ChalkFunction converter
Public API
// packages/tui/src/theme/index.ts
export { type Theme, type SemanticTokens, type ColorPrimitives } from './types.js';
export { darkTheme, lightTheme } from './themes.js';
export { getTheme, setTheme, getThemeNames } from './registry.js';
Theme Registry
// packages/tui/src/theme/registry.ts
import { darkTheme, lightTheme } from './themes.js';
import type { Theme } from './types.js';
const themes = new Map<string, Theme>([
['dark', darkTheme],
['light', lightTheme],
]);
let currentTheme: Theme = darkTheme;
export function getTheme(): Theme {
return currentTheme;
}
export function setTheme(name: string): void {
const theme = themes.get(name);
if (!theme) {
throw new Error(`Theme "${name}" not found`);
}
currentTheme = theme;
}
export function getThemeNames(): string[] {
return Array.from(themes.keys());
}
export function registerTheme(theme: Theme): void {
themes.set(theme.name, theme);
}
export function getThemeByName(name: string): Theme | undefined {
return themes.get(name);
}
JSON Theme Parser
// packages/tui/src/theme/parser.ts
import chalk from 'chalk';
import type { ChalkFunction } from './types.js';
type ChalkColorName =
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray"
| "blackBright" | "redBright" | "greenBright" | "yellowBright"
| "blueBright" | "magentaBright" | "cyanBright" | "whiteBright"
| "bold" | "dim" | "italic" | "underline" | "strikethrough" | "inverse"
| "none";
type JsonThemeValue = ChalkColorName | ChalkColorName[] | string;
interface JsonTheme {
name: string;
extends?: string;
primitives?: Record<string, JsonThemeValue>;
tokens?: any; // Partial<SemanticTokens> but with JsonThemeValue instead of ChalkFunction
}
// Map chalk color names to actual chalk functions
const chalkMap: Record<ChalkColorName, any> = {
black: chalk.black,
red: chalk.red,
green: chalk.green,
yellow: chalk.yellow,
blue: chalk.blue,
magenta: chalk.magenta,
cyan: chalk.cyan,
white: chalk.white,
gray: chalk.gray,
blackBright: chalk.blackBright,
redBright: chalk.redBright,
greenBright: chalk.greenBright,
yellowBright: chalk.yellowBright,
blueBright: chalk.blueBright,
magentaBright: chalk.magentaBright,
cyanBright: chalk.cyanBright,
whiteBright: chalk.whiteBright,
bold: chalk.bold,
dim: chalk.dim,
italic: chalk.italic,
underline: chalk.underline,
strikethrough: chalk.strikethrough,
inverse: chalk.inverse,
none: (s: string) => s,
};
export function parseThemeValue(
value: JsonThemeValue,
primitives?: Record<string, ChalkFunction>
): ChalkFunction {
// Handle primitive reference: "$gray400"
if (typeof value === 'string' && value.startsWith('
## Migration Strategy
### Phase 1: Infrastructure
1. Create theme module with types, primitives, and built-in themes
2. Export from `@mariozechner/pi-tui`
3. Add tests for theme functions
### Phase 2: Component Migration (Priority Order)
1. **Markdown** (biggest impact, 50+ color calls)
2. **ToolExecution** (stdout/stderr readability)
3. **SelectList** (used everywhere)
4. **Footer** (always visible)
5. **TuiRenderer** (logo, instructions)
6. Other components
### Phase 3: Persistence & UI
1. Add theme to SettingsManager
2. Create ThemeSelector component
3. Add `/theme` slash command
4. Initialize theme on startup
### Example Migration
**Before:**
```typescript
// markdown.ts
if (headingLevel === 1) {
lines.push(chalk.bold.underline.yellow(headingText));
} else if (headingLevel === 2) {
lines.push(chalk.bold.yellow(headingText));
} else {
lines.push(chalk.bold(headingPrefix + headingText));
}
After:
// markdown.ts
import { getTheme } from '@mariozechner/pi-tui/theme';
const theme = getTheme();
if (headingLevel === 1) {
lines.push(theme.tokens.markdown.heading.h1(headingText));
} else if (headingLevel === 2) {
lines.push(theme.tokens.markdown.heading.h2(headingText));
} else {
lines.push(theme.tokens.markdown.heading.h3(headingPrefix + headingText));
}
Benefits of This Approach
- Separation of Concerns: Color values (primitives) separate from usage (tokens)
- Maintainable: Change all headings by editing one token mapping
- Extensible: Easy to add new themes without touching components
- Type-safe: Full TypeScript support
- Testable: Can test themes independently
- Minimal: Only what we need, no over-engineering
- Composable: Can chain primitives (bold + underline + color)
Key Differences from Themes.md
-
Two-layer system: Primitives + Semantic tokens (vs. flat theme object)
-
Composability: Can combine primitive modifiers
-
Better light theme: Properly handles chalk.dim and color visibility issues
-
More organized: Tokens grouped by purpose (text, interactive, markdown, etc.)
-
Easier to extend: Add new token without changing primitives
-
Better for sharing: Could export just primitives for custom themes )) { const primitiveName = value.slice(1); if (primitives && primitives[primitiveName]) { return primitives[primitiveName]; } throw new Error(
Primitive reference "${value}" not found); }// Handle array of chalk names (composition): ["bold", "blue"] if (Array.isArray(value)) { return (str: string) => { let result = str; for (const name of value) { const chalkFn = chalkMap[name as ChalkColorName]; if (!chalkFn) { throw new Error(
Unknown chalk function: ${name}); } result = chalkFn(result); } return result; }; }// Handle single chalk name: "blue" if (typeof value === 'string') { const chalkFn = chalkMap[value as ChalkColorName]; if (!chalkFn) { throw new Error(
Unknown chalk function: ${value}); } return chalkFn; }throw new Error(
Invalid theme value: ${JSON.stringify(value)}); }
// Deep merge objects, used for extending themes function deepMerge(target: any, source: any): any { const result = { ...target };
for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = deepMerge(target[key] || {}, source[key]); } else { result[key] = source[key]; } }
return result; }
export function parseJsonTheme(json: JsonTheme, baseTheme?: Theme): Theme { // Start with base theme if extending let primitives: Record<string, ChalkFunction> = {}; let tokens: any = {};
if (json.extends && baseTheme) { // Copy base theme primitives and tokens primitives = { ...baseTheme.primitives }; tokens = deepMerge({}, baseTheme.tokens); }
// Parse and override primitives if (json.primitives) { for (const [key, value] of Object.entries(json.primitives)) { primitives[key] = parseThemeValue(value, primitives); } }
// Parse and override tokens (recursive) if (json.tokens) { const parsedTokens = parseTokens(json.tokens, primitives); tokens = deepMerge(tokens, parsedTokens); }
return { name: json.name, primitives, tokens, }; }
function parseTokens(obj: any, primitives: Record<string, ChalkFunction>): any { const result: any = {};
for (const [key, value] of Object.entries(obj)) { if (value && typeof value === 'object' && !Array.isArray(value)) { // Nested object, recurse result[key] = parseTokens(value, primitives); } else { // Leaf value, parse it result[key] = parseThemeValue(value as JsonThemeValue, primitives); } }
return result; }
### JSON Theme Loader
```typescript
// packages/tui/src/theme/loader.ts
import { existsSync, readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { parseJsonTheme } from './parser.js';
import { getThemeByName, registerTheme } from './registry.js';
import type { Theme } from './types.js';
export function loadUserThemes(themesDir: string): Theme[] {
const themes: Theme[] = [];
if (!existsSync(themesDir)) {
return themes;
}
const files = readdirSync(themesDir).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const content = readFileSync(join(themesDir, file), 'utf-8');
const json = JSON.parse(content);
// Get base theme if extending
let baseTheme: Theme | undefined;
if (json.extends) {
baseTheme = getThemeByName(json.extends);
if (!baseTheme) {
console.warn(`Theme ${json.name} extends unknown theme "${json.extends}", skipping`);
continue;
}
}
const theme = parseJsonTheme(json, baseTheme);
registerTheme(theme);
themes.push(theme);
} catch (error) {
console.error(`Failed to load theme from ${file}:`, error);
}
}
return themes;
}
Migration Strategy
Phase 1: Infrastructure
- Create theme module with types, primitives, and built-in themes
- Export from
@mariozechner/pi-tui - Add tests for theme functions
Phase 2: Component Migration (Priority Order)
- Markdown (biggest impact, 50+ color calls)
- ToolExecution (stdout/stderr readability)
- SelectList (used everywhere)
- Footer (always visible)
- TuiRenderer (logo, instructions)
- Other components
Phase 3: Persistence & UI
- Add theme to SettingsManager
- Create ThemeSelector component
- Add
/themeslash command - Initialize theme on startup
Example Migration
Before:
// markdown.ts
if (headingLevel === 1) {
lines.push(chalk.bold.underline.yellow(headingText));
} else if (headingLevel === 2) {
lines.push(chalk.bold.yellow(headingText));
} else {
lines.push(chalk.bold(headingPrefix + headingText));
}
After:
// markdown.ts
import { getTheme } from '@mariozechner/pi-tui/theme';
const theme = getTheme();
if (headingLevel === 1) {
lines.push(theme.tokens.markdown.heading.h1(headingText));
} else if (headingLevel === 2) {
lines.push(theme.tokens.markdown.heading.h2(headingText));
} else {
lines.push(theme.tokens.markdown.heading.h3(headingPrefix + headingText));
}
Benefits of This Approach
- Separation of Concerns: Color values (primitives) separate from usage (tokens)
- Maintainable: Change all headings by editing one token mapping
- Extensible: Easy to add new themes without touching components
- Type-safe: Full TypeScript support
- Testable: Can test themes independently
- Minimal: Only what we need, no over-engineering
- Composable: Can chain primitives (bold + underline + color)
Key Differences from Themes.md
- Two-layer system: Primitives + Semantic tokens (vs. flat theme object)
- Composability: Can combine primitive modifiers
- Better light theme: Properly handles chalk.dim and color visibility issues
- More organized: Tokens grouped by purpose (text, interactive, markdown, etc.)
- Easier to extend: Add new token without changing primitives
- Better for sharing: Could export just primitives for custom themes