gentle-pi 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,8 @@ import { fileURLToPath } from "node:url";
19
19
  import type {
20
20
  ExtensionAPI,
21
21
  ExtensionContext,
22
+ Theme,
23
+ ThemeColor,
22
24
  ToolCallEventResult,
23
25
  } from "@earendil-works/pi-coding-agent";
24
26
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
@@ -45,6 +47,7 @@ import {
45
47
  type ChangedDiff,
46
48
  type TriggerEvent,
47
49
  } from "../lib/review-triggers.ts";
50
+ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
48
51
 
49
52
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
50
53
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
@@ -713,17 +716,8 @@ function isThinkingLevel(value: unknown): value is ThinkingLevel {
713
716
  );
714
717
  }
715
718
 
716
- const ANSI_ESCAPE_PATTERN =
717
- /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
718
- const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
719
719
  const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
720
720
 
721
- function sanitizeTerminalText(value: string): string {
722
- return value
723
- .replace(ANSI_ESCAPE_PATTERN, "")
724
- .replace(CONTROL_CHAR_PATTERN, "");
725
- }
726
-
727
721
  function normalizeModelId(value: unknown): string | undefined {
728
722
  if (typeof value !== "string") return undefined;
729
723
  const model = value.trim();
@@ -1236,6 +1230,26 @@ type ModelPanelResult =
1236
1230
 
1237
1231
  const SET_ALL_AGENTS = "Set all agents";
1238
1232
 
1233
+ const PANEL_TONE = {
1234
+ BORDER: "border",
1235
+ MUTED: "muted",
1236
+ TEXT: "text",
1237
+ TITLE: "title",
1238
+ ACCENT: "accent",
1239
+ STATUS: "status",
1240
+ } as const;
1241
+
1242
+ type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE];
1243
+
1244
+ const PANEL_TONE_COLOR: Record<PanelTone, ThemeColor> = {
1245
+ border: "border",
1246
+ muted: "muted",
1247
+ text: "text",
1248
+ title: "accent",
1249
+ accent: "accent",
1250
+ status: "thinkingHigh",
1251
+ };
1252
+
1239
1253
  class SddModelPanel implements OverlayComponent {
1240
1254
  private cursor = 0;
1241
1255
  private mode: "agents" | "models" | "effort" = "agents";
@@ -1247,17 +1261,20 @@ class SddModelPanel implements OverlayComponent {
1247
1261
  private readonly rows: string[];
1248
1262
  private readonly modelOptions: string[];
1249
1263
  private readonly done: (result: ModelPanelResult) => void;
1264
+ private readonly theme: Theme | undefined;
1250
1265
 
1251
1266
  constructor(
1252
1267
  initialConfig: AgentModelConfig,
1253
1268
  modelOptions: string[],
1254
1269
  agents: string[],
1255
1270
  done: (result: ModelPanelResult) => void,
1271
+ theme?: Theme,
1256
1272
  ) {
1257
1273
  this.draft = cloneModelConfig(initialConfig);
1258
1274
  this.rows = [SET_ALL_AGENTS, ...agents];
1259
1275
  this.modelOptions = modelOptions;
1260
1276
  this.done = done;
1277
+ this.theme = theme;
1261
1278
  }
1262
1279
 
1263
1280
  invalidate(): void {}
@@ -1287,15 +1304,46 @@ class SddModelPanel implements OverlayComponent {
1287
1304
 
1288
1305
  private renderCard(lines: string[], width: number): string[] {
1289
1306
  const innerWidth = Math.max(1, width - 4);
1290
- const fit = (text = "") =>
1291
- truncateToWidth(sanitizeTerminalText(text), innerWidth, "", true).padEnd(innerWidth);
1307
+ const horizontal = "─".repeat(innerWidth + 2);
1308
+ const border = (text: string) => this.renderText(text, "border");
1292
1309
  return [
1293
- `╭${"─".repeat(innerWidth + 2)}╮`,
1294
- ...lines.map((line) => `│ ${fit(line)} │`),
1295
- `╰${"─".repeat(innerWidth + 2)}╯`,
1310
+ border(`╭${horizontal}╮`),
1311
+ ...lines.map(
1312
+ (line) =>
1313
+ `${border("│")} ${this.fitStyledLine(line, innerWidth)} ${border("│")}`,
1314
+ ),
1315
+ border(`╰${horizontal}╯`),
1296
1316
  ];
1297
1317
  }
1298
1318
 
1319
+ private fitStyledLine(line: string, width: number): string {
1320
+ const visible = stripAnsi(line);
1321
+ if (visible.length > width) {
1322
+ return truncateToWidth(visible, Math.max(1, width), "…", true);
1323
+ }
1324
+ return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
1325
+ }
1326
+
1327
+ private renderLine(text = "", width: number, tone?: PanelTone): string {
1328
+ const safe = truncateToWidth(
1329
+ sanitizeTerminalText(text),
1330
+ Math.max(1, width),
1331
+ "…",
1332
+ true,
1333
+ );
1334
+ return tone ? this.renderText(safe, tone) : safe;
1335
+ }
1336
+
1337
+ private renderText(text: string, tone: PanelTone): string {
1338
+ const safe = sanitizeTerminalText(text);
1339
+ if (!this.theme) return safe;
1340
+ return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
1341
+ }
1342
+
1343
+ private renderCursor(focused: boolean): string {
1344
+ return focused ? this.renderText("▸", "accent") : " ";
1345
+ }
1346
+
1299
1347
  private handleAgentInput(data: string): void {
1300
1348
  const maxCursor = this.rows.length + 1;
1301
1349
  if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
@@ -1478,11 +1526,11 @@ class SddModelPanel implements OverlayComponent {
1478
1526
 
1479
1527
  private renderAgentList(width: number): string[] {
1480
1528
  const lines: string[] = [];
1481
- const line = (text = "") =>
1482
- truncateToWidth(text, Math.max(1, width), "…", true);
1483
- lines.push(line("Assign Models and Effort to Agents"));
1529
+ const line = (text = "", tone?: PanelTone) =>
1530
+ this.renderLine(text, width, tone);
1531
+ lines.push(line("Assign Models and Effort to Agents", "title"));
1484
1532
  lines.push("");
1485
- lines.push(line("Current assignments:"));
1533
+ lines.push(line("Current assignments:", "muted"));
1486
1534
  lines.push("");
1487
1535
  const visibleRows = Math.min(AGENT_LIST_MAX_VISIBLE_ROWS, this.rows.length);
1488
1536
  const listCursor = Math.min(this.cursor, this.rows.length - 1);
@@ -1494,7 +1542,7 @@ class SddModelPanel implements OverlayComponent {
1494
1542
  ),
1495
1543
  );
1496
1544
  const end = Math.min(this.rows.length, start + visibleRows);
1497
- if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`));
1545
+ if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`, "muted"));
1498
1546
  for (let i = start; i < end; i++) {
1499
1547
  const row = this.rows[i] ?? SET_ALL_AGENTS;
1500
1548
  const focused = i === this.cursor;
@@ -1502,21 +1550,28 @@ class SddModelPanel implements OverlayComponent {
1502
1550
  row === SET_ALL_AGENTS
1503
1551
  ? this.renderSetAllLabel(row)
1504
1552
  : this.renderAgentLabel(row);
1505
- lines.push(line(`${focused ? "▸" : " "} ${label}`));
1553
+ lines.push(`${this.renderCursor(focused)} ${label}`);
1506
1554
  }
1507
1555
  if (end < this.rows.length)
1508
- lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`));
1556
+ lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`, "muted"));
1509
1557
  lines.push("");
1510
1558
  lines.push(
1511
- line(`${this.cursor === this.rows.length ? "▸" : " "} Continue`),
1559
+ `${this.renderCursor(this.cursor === this.rows.length)} ${this.renderText(
1560
+ "Continue",
1561
+ this.cursor === this.rows.length ? "accent" : "text",
1562
+ )}`,
1512
1563
  );
1513
1564
  lines.push(
1514
- line(`${this.cursor === this.rows.length + 1 ? "▸" : " "} ← Back`),
1565
+ `${this.renderCursor(this.cursor === this.rows.length + 1)} ${this.renderText(
1566
+ "← Back",
1567
+ this.cursor === this.rows.length + 1 ? "accent" : "text",
1568
+ )}`,
1515
1569
  );
1516
1570
  lines.push("");
1517
1571
  lines.push(
1518
1572
  line(
1519
1573
  "j/k scroll • enter model/save • e effort • i inherit • c custom • x export • r restore • ctrl+s save • esc back",
1574
+ "muted",
1520
1575
  ),
1521
1576
  );
1522
1577
  return lines;
@@ -1525,11 +1580,15 @@ class SddModelPanel implements OverlayComponent {
1525
1580
  private renderModelPicker(width: number): string[] {
1526
1581
  const lines: string[] = [];
1527
1582
  const options = this.filteredModelOptions();
1528
- const line = (text = "") =>
1529
- truncateToWidth(text, Math.max(1, width), "…", true);
1530
- lines.push(line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`));
1583
+ const line = (text = "", tone?: PanelTone) =>
1584
+ this.renderLine(text, width, tone);
1585
+ lines.push(
1586
+ line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
1587
+ );
1531
1588
  lines.push("");
1532
- lines.push(line(`◎ ${this.query || "search..."}`));
1589
+ lines.push(
1590
+ `${this.renderText("◎", "accent")} ${this.renderText(this.query || "search...", "muted")}`,
1591
+ );
1533
1592
  lines.push("");
1534
1593
  const start = Math.max(
1535
1594
  0,
@@ -1541,12 +1600,17 @@ class SddModelPanel implements OverlayComponent {
1541
1600
  const end = Math.min(options.length, start + MODEL_LIST_MAX_VISIBLE_ROWS);
1542
1601
  for (let i = start; i < end; i++) {
1543
1602
  const focused = i === this.modelCursor;
1544
- lines.push(line(`${focused ? "▸" : " "} ${sanitizeTerminalText(options[i] ?? "")}`));
1603
+ lines.push(
1604
+ `${this.renderCursor(focused)} ${this.renderText(
1605
+ options[i] ?? "",
1606
+ focused ? "status" : "text",
1607
+ )}`,
1608
+ );
1545
1609
  }
1546
- if (options.length === 0) lines.push(line(" No matching models"));
1610
+ if (options.length === 0) lines.push(line(" No matching models", "muted"));
1547
1611
  lines.push("");
1548
1612
  lines.push(
1549
- line("j/k: navigate • type: search • enter: select • esc: back"),
1613
+ line("j/k: navigate • type: search • enter: select • esc: back", "muted"),
1550
1614
  );
1551
1615
  return lines;
1552
1616
  }
@@ -1580,16 +1644,23 @@ class SddModelPanel implements OverlayComponent {
1580
1644
 
1581
1645
  private renderEffortPicker(width: number): string[] {
1582
1646
  const lines: string[] = [];
1583
- const line = (text = "") =>
1584
- truncateToWidth(text, Math.max(1, width), "…", true);
1585
- lines.push(line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`));
1647
+ const line = (text = "", tone?: PanelTone) =>
1648
+ this.renderLine(text, width, tone);
1649
+ lines.push(
1650
+ line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
1651
+ );
1586
1652
  lines.push("");
1587
1653
  for (let i = 0; i < THINKING_OPTIONS.length; i++) {
1588
1654
  const focused = i === this.effortCursor;
1589
- lines.push(line(`${focused ? "▸" : " "} ${THINKING_OPTIONS[i]}`));
1655
+ lines.push(
1656
+ `${this.renderCursor(focused)} ${this.renderText(
1657
+ THINKING_OPTIONS[i] ?? "",
1658
+ focused ? "status" : "text",
1659
+ )}`,
1660
+ );
1590
1661
  }
1591
1662
  lines.push("");
1592
- lines.push(line("j/k: navigate • enter: select • esc: back"));
1663
+ lines.push(line("j/k: navigate • enter: select • esc: back", "muted"));
1593
1664
  return lines;
1594
1665
  }
1595
1666
 
@@ -1608,16 +1679,34 @@ class SddModelPanel implements OverlayComponent {
1608
1679
  const effortLabel = efforts.every((value) => value === firstEffort)
1609
1680
  ? firstEffort
1610
1681
  : "mixed";
1611
- return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(modelLabel)}, effort=${sanitizeTerminalText(effortLabel)}`;
1682
+ return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(modelLabel, "status")}${this.renderText(
1683
+ ", effort=",
1684
+ "muted",
1685
+ )}${this.renderText(effortLabel, "status")}`;
1612
1686
  }
1613
1687
 
1614
1688
  private renderAgentLabel(row: string): string {
1615
1689
  const model = this.draft[row]?.model ?? "inherit";
1616
1690
  const effort = this.draft[row]?.thinking ?? "inherit";
1617
- return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(effort)}`;
1691
+ return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
1692
+ ", effort=",
1693
+ "muted",
1694
+ )}${this.renderText(effort, "status")}`;
1618
1695
  }
1619
1696
  }
1620
1697
 
1698
+ function renderSddModelPanelForTesting(
1699
+ initialConfig: AgentModelConfig,
1700
+ modelOptions: string[],
1701
+ agents: string[],
1702
+ width: number,
1703
+ theme?: Theme,
1704
+ ): string[] {
1705
+ return new SddModelPanel(initialConfig, modelOptions, agents, () => {}, theme).render(
1706
+ width,
1707
+ );
1708
+ }
1709
+
1621
1710
  async function showSddModelPanel(
1622
1711
  ctx: ExtensionContext,
1623
1712
  config: AgentModelConfig,
@@ -1625,8 +1714,8 @@ async function showSddModelPanel(
1625
1714
  const modelOptions = await getPiModelOptions(ctx);
1626
1715
  const agents = listDiscoverableAgents(ctx.cwd).map((agent) => agent.name);
1627
1716
  return ctx.ui.custom<ModelPanelResult>(
1628
- (_tui, _theme, _keybindings, done) =>
1629
- new SddModelPanel(config, modelOptions, agents, done),
1717
+ (_tui, theme, _keybindings, done) =>
1718
+ new SddModelPanel(config, modelOptions, agents, done, theme),
1630
1719
  {
1631
1720
  overlay: true,
1632
1721
  overlayOptions: {
@@ -1918,6 +2007,7 @@ export const __testing = {
1918
2007
  buildGentlePrompt,
1919
2008
  classifyReviewEvent,
1920
2009
  parseNumstat,
2010
+ renderSddModelPanel: renderSddModelPanelForTesting,
1921
2011
  };
1922
2012
 
1923
2013
  export default function gentleAi(pi: ExtensionAPI): void {
@@ -0,0 +1,16 @@
1
+ const NON_OSC_ANSI_ESCAPE_PATTERN =
2
+ /\x1B\[[0-?]*[ -/]*[@-~]|\x1B[@-Z\\-_]|\x9B[0-?]*[ -/]*[@-~]/g;
3
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
4
+ const OSC_ESCAPE_PATTERN = /(?:\x1B\]|\x9D)[\s\S]*?(?:\x07|\x1B\\|\x9C|$)/g;
5
+
6
+ export function stripAnsi(value: string): string {
7
+ return stripOsc(value).replace(NON_OSC_ANSI_ESCAPE_PATTERN, "");
8
+ }
9
+
10
+ function stripOsc(value: string): string {
11
+ return value.replace(OSC_ESCAPE_PATTERN, "");
12
+ }
13
+
14
+ export function sanitizeTerminalText(value: string): string {
15
+ return stripAnsi(value).replace(CONTROL_CHAR_PATTERN, "");
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "skills/",
32
32
  "scripts/",
33
33
  "tests/",
34
+ "themes/",
34
35
  "README.md"
35
36
  ],
36
37
  "scripts": {
@@ -44,6 +45,9 @@
44
45
  "extensions": [
45
46
  "./extensions"
46
47
  ],
48
+ "themes": [
49
+ "./themes"
50
+ ],
47
51
  "prompts": [
48
52
  "./prompts"
49
53
  ],
@@ -3,7 +3,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
7
  import { __testing } from "../extensions/gentle-ai.ts";
8
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
9
 
8
10
  function writeMarkdown(path: string, content: string): void {
9
11
  mkdirSync(dirname(path), { recursive: true });
@@ -83,3 +85,35 @@ test("discoverable model agents include installed Judgment Day agents", (t) => {
83
85
  ["jd-judge-a", "jd-judge-b", "jd-fix-agent"],
84
86
  );
85
87
  });
88
+
89
+ test("model panel render does not auto-apply the Gentle theme and sanitizes agent labels", () => {
90
+ const lines = __testing.renderSddModelPanel(
91
+ {},
92
+ ["openai/gpt-5.5"],
93
+ ["safe-agent\x1b[31m"],
94
+ 72,
95
+ );
96
+ const rendered = lines.join("\n");
97
+ const plain = stripAnsi(rendered);
98
+
99
+ assert.doesNotMatch(rendered, /\x1b\[38;2;71;85;105m/);
100
+ assert.doesNotMatch(rendered, /\x1b\[38;2;125;211;252m/);
101
+ assert.match(plain, /Assign Models and Effort to Agents/);
102
+ assert.match(plain, /safe-agent\s+model=inherit, effort=inherit/);
103
+ assert.doesNotMatch(plain, /\[31m/);
104
+ });
105
+
106
+ test("model panel render uses the Pi-provided current theme when supplied", () => {
107
+ const currentTheme = {
108
+ fg(_color: string, text: string): string {
109
+ return `\x1b[35m${text}\x1b[39m`;
110
+ },
111
+ } as unknown as Theme;
112
+
113
+ const rendered = __testing
114
+ .renderSddModelPanel({}, ["openai/gpt-5.5"], ["safe-agent"], 72, currentTheme)
115
+ .join("\n");
116
+
117
+ assert.match(rendered, /\x1b\[35m/);
118
+ assert.match(stripAnsi(rendered), /Assign Models and Effort to Agents/);
119
+ });
@@ -0,0 +1,221 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
+
9
+ const REQUIRED_THEME_COLOR_KEYS = [
10
+ "accent",
11
+ "border",
12
+ "borderAccent",
13
+ "borderMuted",
14
+ "success",
15
+ "error",
16
+ "warning",
17
+ "muted",
18
+ "dim",
19
+ "text",
20
+ "thinkingText",
21
+ "selectedBg",
22
+ "userMessageBg",
23
+ "userMessageText",
24
+ "customMessageBg",
25
+ "customMessageText",
26
+ "customMessageLabel",
27
+ "toolPendingBg",
28
+ "toolSuccessBg",
29
+ "toolErrorBg",
30
+ "toolTitle",
31
+ "toolOutput",
32
+ "mdHeading",
33
+ "mdLink",
34
+ "mdLinkUrl",
35
+ "mdCode",
36
+ "mdCodeBlock",
37
+ "mdCodeBlockBorder",
38
+ "mdQuote",
39
+ "mdQuoteBorder",
40
+ "mdHr",
41
+ "mdListBullet",
42
+ "toolDiffAdded",
43
+ "toolDiffRemoved",
44
+ "toolDiffContext",
45
+ "syntaxComment",
46
+ "syntaxKeyword",
47
+ "syntaxFunction",
48
+ "syntaxVariable",
49
+ "syntaxString",
50
+ "syntaxNumber",
51
+ "syntaxType",
52
+ "syntaxOperator",
53
+ "syntaxPunctuation",
54
+ "thinkingOff",
55
+ "thinkingMinimal",
56
+ "thinkingLow",
57
+ "thinkingMedium",
58
+ "thinkingHigh",
59
+ "thinkingXhigh",
60
+ "bashMode",
61
+ ] as const;
62
+
63
+ interface PackageJsonPiManifest {
64
+ theme?: string;
65
+ themes?: string[];
66
+ }
67
+
68
+ interface PackageJson {
69
+ files?: string[];
70
+ pi?: PackageJsonPiManifest;
71
+ }
72
+
73
+ interface GentleThemeJson {
74
+ name?: string;
75
+ vars?: Record<string, unknown>;
76
+ colors?: Record<string, unknown>;
77
+ export?: Record<string, unknown>;
78
+ }
79
+
80
+ function resolveThemeColor(theme: GentleThemeJson, colorKey: string): unknown {
81
+ const colorRef = theme.colors?.[colorKey];
82
+
83
+ if (typeof colorRef !== "string") {
84
+ return colorRef;
85
+ }
86
+
87
+ return theme.vars?.[colorRef] ?? colorRef;
88
+ }
89
+
90
+ function assertResolvedThemeColor(
91
+ theme: GentleThemeJson,
92
+ colorKey: string,
93
+ expected: string,
94
+ ): void {
95
+ assert.equal(resolveThemeColor(theme, colorKey), expected, colorKey);
96
+ }
97
+
98
+ function readJson<T>(path: string): T {
99
+ return JSON.parse(readFileSync(path, "utf8")) as T;
100
+ }
101
+
102
+ test("package manifest exposes bundled themes to Pi discovery", () => {
103
+ const packageJson = readJson<PackageJson>(join(PACKAGE_ROOT, "package.json"));
104
+
105
+ assert.ok(
106
+ packageJson.pi?.themes?.includes("./themes"),
107
+ "package.json must expose ./themes so Pi can list bundled themes in Settings",
108
+ );
109
+ assert.ok(
110
+ packageJson.files?.includes("themes/"),
111
+ "package files must include themes/ so the bundled theme ships in npm packages",
112
+ );
113
+ assert.equal(
114
+ packageJson.pi?.theme,
115
+ undefined,
116
+ "package manifest must not auto-apply the bundled theme",
117
+ );
118
+ });
119
+
120
+ test("bundled Pi theme is named exactly Gentle and defines all required colors", () => {
121
+ const theme = readJson<GentleThemeJson>(
122
+ join(PACKAGE_ROOT, "themes", "Gentle.json"),
123
+ );
124
+
125
+ assert.equal(theme.name, "Gentle");
126
+ const colors = theme.colors ?? {};
127
+ assert.deepEqual(
128
+ REQUIRED_THEME_COLOR_KEYS.filter((key) => !(key in colors)),
129
+ [],
130
+ );
131
+ });
132
+
133
+ test("bundled Pi theme maps roles to the subtle OpenCode gentleman theme", () => {
134
+ const theme = readJson<GentleThemeJson>(
135
+ join(PACKAGE_ROOT, "themes", "Gentle.json"),
136
+ );
137
+ const vars = theme.vars ?? {};
138
+ const colors = theme.colors ?? {};
139
+
140
+ assert.equal(vars.bg, "#06080f", "Pi background should match OpenCode panel background");
141
+ assert.equal(vars.bgPanel, "#06080f", "large panel surfaces should match OpenCode panels");
142
+ assert.equal(vars.bgElement, "#06080f", "message surfaces should match OpenCode elements");
143
+ assert.equal(vars.bgSubtle, "#0d0f14", "secondary surfaces should use OpenCode diff context background");
144
+ assert.equal(vars.text, "#F3F6F9", "text should match OpenCode foreground");
145
+ assert.equal(vars.muted, "#5C6170", "muted text should match OpenCode textMuted");
146
+ assert.equal(vars.border, "#313342", "border should match OpenCode border");
147
+ assert.equal(vars.borderSubtle, "#232A40", "subtle borders should match OpenCode borderSubtle");
148
+ assert.equal(vars.accent, "#E0C15A", "source accent should remain available");
149
+ assert.equal(vars.selection, "#232A40", "selection should use OpenCode borderSubtle");
150
+ assert.equal(vars.red, "#CB7C94", "error accent should use blur red");
151
+ assert.equal(vars.green, "#B7CC85", "success accent should use blur green");
152
+ assert.equal(vars.blue, "#7FB4CA", "primary roles should use OpenCode primary blue");
153
+ assert.equal(vars.secondary, "#A3B5D6", "secondary roles should match OpenCode secondary");
154
+ assert.equal(vars.heading, "#B5B2D0", "markdown headings should match OpenCode heading");
155
+ assert.equal(vars.warning, "#DEBA87", "warning should match OpenCode warning");
156
+ assert.equal(
157
+ vars.toolSuccessBg,
158
+ "#1a2e1a",
159
+ "success background should match OpenCode diff added background",
160
+ );
161
+ assert.equal(
162
+ vars.toolPendingBg,
163
+ "#0d0f14",
164
+ "pending background should match OpenCode diff context background",
165
+ );
166
+ assert.equal(
167
+ vars.toolErrorBg,
168
+ "#2e1a1a",
169
+ "error background should match OpenCode diff removed background",
170
+ );
171
+ assert.equal(
172
+ vars.infoBg,
173
+ "#0d0f14",
174
+ "info background should stay as subtle as OpenCode diff context background",
175
+ );
176
+
177
+ assert.equal(colors.accent, "blue");
178
+ assert.equal(colors.selectedBg, "selection");
179
+ assert.equal(colors.borderAccent, "blue");
180
+ assert.equal(colors.userMessageBg, "bgElement");
181
+ assert.equal(colors.customMessageBg, "bgSubtle");
182
+ assert.notEqual(resolveThemeColor(theme, "borderAccent"), "#E0C15A");
183
+ assert.equal(colors.thinkingText, "muted");
184
+ assert.equal(colors.bashMode, "blue");
185
+ assert.equal(colors.toolPendingBg, "toolPendingBg");
186
+ assert.equal(colors.toolSuccessBg, "toolSuccessBg");
187
+ assert.equal(colors.toolErrorBg, "toolErrorBg");
188
+ assert.equal(colors.mdHeading, "heading");
189
+ assert.equal(colors.mdLink, "blue");
190
+ assert.equal(colors.mdCode, "green");
191
+ assert.equal(colors.mdQuote, "warning");
192
+ assert.equal(colors.mdListBullet, "blue");
193
+ assert.equal(colors.toolTitle, "blue");
194
+ assert.equal(colors.thinkingHigh, "blue");
195
+ assert.equal(colors.thinkingXhigh, "secondary");
196
+ assert.equal(colors.toolOutput, "text");
197
+ assert.equal(theme.export?.pageBg, "bg");
198
+ assert.equal(theme.export?.cardBg, "bgElement");
199
+
200
+ assertResolvedThemeColor(theme, "accent", "#7FB4CA");
201
+ assertResolvedThemeColor(theme, "border", "#313342");
202
+ assertResolvedThemeColor(theme, "borderMuted", "#232A40");
203
+ assertResolvedThemeColor(theme, "borderAccent", "#7FB4CA");
204
+ assertResolvedThemeColor(theme, "selectedBg", "#232A40");
205
+ assertResolvedThemeColor(theme, "thinkingText", "#5C6170");
206
+ assertResolvedThemeColor(theme, "bashMode", "#7FB4CA");
207
+ assertResolvedThemeColor(theme, "toolSuccessBg", "#1a2e1a");
208
+ assertResolvedThemeColor(theme, "toolErrorBg", "#2e1a1a");
209
+ assertResolvedThemeColor(theme, "toolPendingBg", "#0d0f14");
210
+ assertResolvedThemeColor(theme, "mdHeading", "#B5B2D0");
211
+ assertResolvedThemeColor(theme, "mdLink", "#7FB4CA");
212
+ assertResolvedThemeColor(theme, "mdCode", "#B7CC85");
213
+ assertResolvedThemeColor(theme, "mdQuote", "#DEBA87");
214
+ assertResolvedThemeColor(theme, "syntaxComment", "#8394A3");
215
+ assertResolvedThemeColor(theme, "syntaxKeyword", "#C99AD6");
216
+ assertResolvedThemeColor(theme, "syntaxFunction", "#B99BF2");
217
+ assertResolvedThemeColor(theme, "syntaxString", "#DFBD76");
218
+ assertResolvedThemeColor(theme, "syntaxNumber", "#A4DAA7");
219
+ assertResolvedThemeColor(theme, "syntaxType", "#8FB8DD");
220
+ assertResolvedThemeColor(theme, "syntaxPunctuation", "#96A2B0");
221
+ });
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
8
8
  import { matchesKey } from "@earendil-works/pi-tui";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { stripAnsi } from "../lib/terminal-theme.ts";
10
11
 
11
12
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
12
13
  const EXTENSIONS = [
@@ -823,8 +824,9 @@ async function run() {
823
824
  ctx.ui.custom = (factory) => {
824
825
  const panel = factory(null, null, null, () => undefined);
825
826
  const initialLines = panel.render(120);
827
+ const plainInitialLines = initialLines.map(stripAnsi);
826
828
  assert.ok(
827
- initialLines[0].startsWith("╭") && initialLines.at(-1).startsWith("╰"),
829
+ plainInitialLines[0].startsWith("╭") && plainInitialLines.at(-1).startsWith("╰"),
828
830
  "model panel should render inside a bordered card",
829
831
  );
830
832
  assert.ok(
@@ -832,36 +834,43 @@ async function run() {
832
834
  "long model agent list should fit within a 24-row terminal 85% overlay budget",
833
835
  );
834
836
  assert.ok(
835
- initialLines.some((line) => /↓ \d+ more agent\(s\)/.test(line)),
837
+ plainInitialLines.some((line) => /↓ \d+ more agent\(s\)/.test(line)),
836
838
  "long model agent list should render a down-scroll indicator",
837
839
  );
838
840
  assert.ok(
839
- initialLines.some((line) => line.includes("Continue")),
841
+ plainInitialLines.some((line) => line.includes("Continue")),
840
842
  "long model agent list should keep Continue visible",
841
843
  );
842
844
  assert.doesNotMatch(
843
845
  initialLines.join("\n"),
844
- /[\u001b\u0007]/,
845
- "model panel must strip terminal control sequences from agent labels",
846
+ /\u001b\]|\u0007/,
847
+ "model panel must strip unsafe terminal control sequences from agent labels",
848
+ );
849
+ assert.doesNotMatch(
850
+ plainInitialLines.join("\n"),
851
+ /\]52|\[31m/,
852
+ "model panel must strip user-provided terminal escapes from labels",
846
853
  );
847
854
  for (let i = 0; i < 20; i++) panel.handleInput("j");
848
855
  const scrolledLines = panel.render(120);
856
+ const plainScrolledLines = scrolledLines.map(stripAnsi);
849
857
  assert.ok(
850
858
  scrolledLines.length <= 20,
851
859
  "scrolled model agent list should stay within the overlay height budget",
852
860
  );
853
861
  assert.ok(
854
- scrolledLines.some((line) => /↑ \d+ more agent\(s\)/.test(line)),
862
+ plainScrolledLines.some((line) => /↑ \d+ more agent\(s\)/.test(line)),
855
863
  "long model agent list should render an up-scroll indicator after navigation",
856
864
  );
857
865
  panel.handleInput("G");
858
866
  const bottomLines = panel.render(120);
867
+ const plainBottomLines = bottomLines.map(stripAnsi);
859
868
  assert.ok(
860
869
  bottomLines.length <= 20,
861
870
  "bottom model agent list should stay within the overlay height budget",
862
871
  );
863
872
  assert.ok(
864
- bottomLines.some((line) => line.includes("▸ ← Back")),
873
+ plainBottomLines.some((line) => line.includes("▸ ← Back")),
865
874
  "G should jump to the Back action",
866
875
  );
867
876
  return Promise.resolve({ type: "cancel" });
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
4
+
5
+ test("sanitizeTerminalText removes user-controlled ANSI and control characters", () => {
6
+ assert.equal(
7
+ sanitizeTerminalText("safe\x1b[31mred\x1b[0m\x1b]52;c;Zm9v\u0007text"),
8
+ "saferedtext",
9
+ );
10
+ });
11
+
12
+ test("sanitizeTerminalText removes unterminated OSC payloads", () => {
13
+ assert.equal(sanitizeTerminalText("safe\x1b]52;c;Zm9vtext"), "safe");
14
+ assert.equal(sanitizeTerminalText("safe\x9d52;c;Zm9vtext"), "safe");
15
+ });
16
+
17
+ test("stripAnsi removes OSC payloads and ANSI styling", () => {
18
+ assert.equal(
19
+ stripAnsi("safe \x1b]52;c;Zm9v\u0007\x1b[31mred\x1b[0m"),
20
+ "safe red",
21
+ );
22
+ });
@@ -0,0 +1,100 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "Gentle",
4
+ "vars": {
5
+ "bg": "#06080f",
6
+ "bgPanel": "#06080f",
7
+ "bgElement": "#06080f",
8
+ "bgSubtle": "#0d0f14",
9
+ "surfaceLine": "#0d0f14",
10
+ "border": "#313342",
11
+ "borderSubtle": "#232A40",
12
+ "text": "#F3F6F9",
13
+ "muted": "#5C6170",
14
+ "dim": "#5C6170",
15
+ "disabled": "#5C6170",
16
+ "accent": "#E0C15A",
17
+ "blue": "#7FB4CA",
18
+ "secondary": "#A3B5D6",
19
+ "heading": "#B5B2D0",
20
+ "syntaxComment": "#8394A3",
21
+ "syntaxKeyword": "#C99AD6",
22
+ "syntaxFunction": "#B99BF2",
23
+ "syntaxString": "#DFBD76",
24
+ "syntaxNumber": "#A4DAA7",
25
+ "syntaxType": "#8FB8DD",
26
+ "syntaxPunctuation": "#96A2B0",
27
+ "green": "#B7CC85",
28
+ "warning": "#DEBA87",
29
+ "red": "#CB7C94",
30
+ "brightBlack": "#8394A3",
31
+ "brightGreen": "#D1E8A9",
32
+ "brightYellow": "#DEBA87",
33
+ "brightPurple": "#B99BF2",
34
+ "brightMagenta": "#C99AD6",
35
+ "brightBlue": "#8FB8DD",
36
+ "selection": "#232A40",
37
+ "toolSuccessBg": "#1a2e1a",
38
+ "toolPendingBg": "#0d0f14",
39
+ "toolErrorBg": "#2e1a1a",
40
+ "infoBg": "#0d0f14"
41
+ },
42
+ "colors": {
43
+ "accent": "blue",
44
+ "border": "border",
45
+ "borderAccent": "blue",
46
+ "borderMuted": "borderSubtle",
47
+ "success": "green",
48
+ "error": "red",
49
+ "warning": "warning",
50
+ "muted": "muted",
51
+ "dim": "dim",
52
+ "text": "text",
53
+ "thinkingText": "muted",
54
+ "selectedBg": "selection",
55
+ "userMessageBg": "bgElement",
56
+ "userMessageText": "text",
57
+ "customMessageBg": "bgSubtle",
58
+ "customMessageText": "text",
59
+ "customMessageLabel": "blue",
60
+ "toolPendingBg": "toolPendingBg",
61
+ "toolSuccessBg": "toolSuccessBg",
62
+ "toolErrorBg": "toolErrorBg",
63
+ "toolTitle": "blue",
64
+ "toolOutput": "text",
65
+ "mdHeading": "heading",
66
+ "mdLink": "blue",
67
+ "mdLinkUrl": "muted",
68
+ "mdCode": "green",
69
+ "mdCodeBlock": "text",
70
+ "mdCodeBlockBorder": "borderSubtle",
71
+ "mdQuote": "warning",
72
+ "mdQuoteBorder": "borderSubtle",
73
+ "mdHr": "muted",
74
+ "mdListBullet": "blue",
75
+ "toolDiffAdded": "green",
76
+ "toolDiffRemoved": "red",
77
+ "toolDiffContext": "muted",
78
+ "syntaxComment": "syntaxComment",
79
+ "syntaxKeyword": "syntaxKeyword",
80
+ "syntaxFunction": "syntaxFunction",
81
+ "syntaxVariable": "text",
82
+ "syntaxString": "syntaxString",
83
+ "syntaxNumber": "syntaxNumber",
84
+ "syntaxType": "syntaxType",
85
+ "syntaxOperator": "warning",
86
+ "syntaxPunctuation": "syntaxPunctuation",
87
+ "thinkingOff": "borderSubtle",
88
+ "thinkingMinimal": "dim",
89
+ "thinkingLow": "muted",
90
+ "thinkingMedium": "blue",
91
+ "thinkingHigh": "blue",
92
+ "thinkingXhigh": "secondary",
93
+ "bashMode": "blue"
94
+ },
95
+ "export": {
96
+ "pageBg": "bg",
97
+ "cardBg": "bgElement",
98
+ "infoBg": "infoBg"
99
+ }
100
+ }