mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
* feat(coding-agent,tui): add system theme derived from terminal colors The new default system theme builds pi's colors from the terminal's reported foreground, background, and ANSI palette. Tokens take their hue and relative saturation from a palette slot; lightness is placed by OKLab lightness difference and WCAG 2 contrast minimums. Terminals that report only a background get built-in hues, and terminals that report nothing get ANSI indices with faint secondary text. The TUI queries OSC 10, 11, and 4 in one burst ended by DA1, so replies complete in one round trip, and reports replies that arrive after the timeout. Startup never waits: the system theme starts in grayscale and updates when colors arrive. Light/dark detection now uses the reported colors instead of the ?996 query; mode 2031 notifications trigger a new query. * fix(coding-agent): wait for terminal colors before building the startup header The header and startup notices bake theme colors into their text. When the color replies arrived after they were built, as in Windows Terminal, they stayed grayscale. Startup now waits for the first color query, which ends at the DA1 reply or after 100 ms. * fix(coding-agent): rebuild header and notices on theme changes The startup header, loaded resources, and chat notices baked theme colors into their text, so they kept the old colors after a theme switch or when the system theme received the terminal's colors. They now use ThemedText, which rebuilds its text from a function whenever the UI is invalidated. * fix(tui): keep focus on components that forward mouse events to children A component that forwards a mouse event to a child it hosts, such as SettingsList with an open submenu, left keyboard focus on the child. When the host removed the child, focus stayed on a detached component and all keys were lost. The forwarding component now keeps focus, like delegating containers do. * feat(coding-agent): describe the system theme in the theme selector * feat(coding-agent): list the system theme above automatic in the theme selector * refactor(coding-agent,tui): remove leftovers of the light/dark startup detection Theme detection only reports dark or light now; the source and confidence fields only served saving the detected theme. The first-time setup no longer shows the detected appearance. Removes the TUI's unused queryTerminalColorScheme(), queryTerminalBackgroundColor(), queryTerminalForegroundColor(), and parseOsc11BackgroundColor(); queryTerminalColors() covers them. * feat(coding-agent): match the reviewed system and light/dark theme design The system theme now follows the reviewed design: color families with OKHSL saturation curves, palette slots per family, contrast rules per token and panel, relaxation for mid-gray backgrounds, and the terminal foreground for body text. Each contrast level is a target-lightness curve fitted to the reviewed output. The built-in dark and light themes use the reviewed Pi dark and light colors. * feat(coding-agent): show the pi logo in the startup header A two-line pixel logo replaces the app name. Its first line carries the version, its second line the first line of key hints. * feat(tui,coding-agent): support OKHSL colors in themes parseColor() accepts okhsl(H S L), and okhslColor() and colorToOkhsl() construct and read OKHSL colors. OKHSL saturation is relative to the sRGB gamut, so every value is in gamut. Theme files accept okhsl() values; HTML exports convert them to hex. The OKHSL code moves from coding-agent into pi-tui, where colors.ts now shares its Oklab conversion instead of keeping a second copy. * refactor(coding-agent): write the built-in themes in OKHSL The built-in dark and light themes use okhsl() values, with variables for colors that several roles share, so they read as color families and serve as editable templates. Near-identical values collapsed into one; no color changes visibly. * refactor(coding-agent,tui): simplify terminal color and theme code Share the terminal color query between startup and the theme controller, merge the query's resolve/late-reply state into one callback, keep a single global for terminal colors, use oklab.ts arrays directly in colors.ts, and drop leftover helpers and exports. * fix(coding-agent): use one terminal light/dark decision everywhere Theme pairs, the system theme, and Theme.appearance now share getTerminalTheme(): the reported background decides, then the terminal's light/dark report, then COLORFGBG, then dark. COLORFGBG is classified by palette index like Vim (0-6 and 8 dark) and only reads the last field, so rxvt's "default" no longer picks up the foreground index. Simplify the tests added on this branch. * fix(ai): use an OpenRouter model that still exists in beta test OpenRouter removed anthropic/claude-3-haiku, which broke type checking in CI after model regeneration.
311 lines
9.5 KiB
TypeScript
311 lines
9.5 KiB
TypeScript
import assert from "node:assert";
|
|
import { describe, it } from "node:test";
|
|
import { Editor, type EditorTheme } from "../src/components/editor.ts";
|
|
import { Input } from "../src/components/input.ts";
|
|
import { SelectList, type SelectListTheme } from "../src/components/select-list.ts";
|
|
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
|
|
import { Container, type TuiMouseEvent, type TuiMouseEventType } from "../src/tui.ts";
|
|
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
|
import { VirtualTerminal } from "./virtual-terminal.ts";
|
|
|
|
function mouse(type: TuiMouseEventType, x: number, y: number, width = 80, height = 10): TuiMouseEvent {
|
|
return {
|
|
type,
|
|
button: "left",
|
|
x,
|
|
y,
|
|
screenX: x,
|
|
screenY: y,
|
|
width,
|
|
height,
|
|
shift: false,
|
|
alt: false,
|
|
ctrl: false,
|
|
...(type === "click" ? { clickCount: 1 } : {}),
|
|
};
|
|
}
|
|
|
|
const selectTheme: SelectListTheme = {
|
|
selectedPrefix: (text) => text,
|
|
selectedText: (text) => text,
|
|
description: (text) => text,
|
|
scrollInfo: (text) => text,
|
|
noMatch: (text) => text,
|
|
};
|
|
|
|
const settingsTheme: SettingsListTheme = {
|
|
label: (text) => text,
|
|
value: (text) => text,
|
|
description: (text) => text,
|
|
cursor: "> ",
|
|
hint: (text) => text,
|
|
};
|
|
|
|
const editorTheme: EditorTheme = {
|
|
borderColor: (text) => text,
|
|
selectList: selectTheme,
|
|
};
|
|
|
|
class InputOverlay extends Container {
|
|
readonly input = new Input();
|
|
|
|
constructor() {
|
|
super();
|
|
this.addChild(this.input);
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.input.handleInput(data);
|
|
}
|
|
}
|
|
|
|
/** A settings submenu that routes keys to its nested list, like the coding agent's theme submenu. */
|
|
class SubmenuHost extends Container {
|
|
readonly list = new SelectList(
|
|
[
|
|
{ value: "first", label: "First" },
|
|
{ value: "second", label: "Second" },
|
|
],
|
|
5,
|
|
selectTheme,
|
|
);
|
|
|
|
constructor(done: (value?: string) => void) {
|
|
super();
|
|
this.list.onSelect = (item) => done(item.value);
|
|
this.list.onCancel = () => done();
|
|
this.addChild(this.list);
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.list.handleInput(data);
|
|
}
|
|
}
|
|
|
|
describe("mouse-aware components", () => {
|
|
it("positions a single-line input cursor on press", () => {
|
|
const input = new Input();
|
|
input.setValue("hello");
|
|
input.render(20);
|
|
|
|
assert.strictEqual(input.handleMouse(mouse("press", 4, 0, 20, 1))?.handled, true);
|
|
input.handleInput("X");
|
|
assert.strictEqual(input.getValue(), "heXllo");
|
|
});
|
|
|
|
it("selects and activates list rows", () => {
|
|
const list = new SelectList(
|
|
[
|
|
{ value: "a", label: "A" },
|
|
{ value: "b", label: "B" },
|
|
{ value: "c", label: "C" },
|
|
{ value: "d", label: "D" },
|
|
{ value: "e", label: "E" },
|
|
],
|
|
3,
|
|
selectTheme,
|
|
);
|
|
let selected: string | undefined;
|
|
list.onSelect = (item) => {
|
|
selected = item.value;
|
|
};
|
|
|
|
assert.strictEqual(list.handleMouse(mouse("press", 1, 2, 40, 3))?.handled, true);
|
|
assert.strictEqual(list.getSelectedItem()?.value, "c");
|
|
assert.strictEqual(list.handleMouse(mouse("click", 1, 2, 40, 3))?.handled, true);
|
|
assert.strictEqual(selected, "c");
|
|
});
|
|
|
|
it("activates settings rows", () => {
|
|
const changes: Array<{ id: string; value: string }> = [];
|
|
const list = new SettingsList(
|
|
[
|
|
{ id: "mode", label: "Mode", currentValue: "one", values: ["one", "two"] },
|
|
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
|
|
{ id: "third", label: "Third", currentValue: "low", values: ["low", "high"] },
|
|
{ id: "fourth", label: "Fourth", currentValue: "x", values: ["x", "y"] },
|
|
],
|
|
3,
|
|
settingsTheme,
|
|
(id, value) => changes.push({ id, value }),
|
|
() => {},
|
|
);
|
|
|
|
list.handleMouse(mouse("press", 1, 2, 40, 5));
|
|
list.handleMouse(mouse("click", 1, 2, 40, 5));
|
|
assert.deepStrictEqual(changes, [{ id: "third", value: "high" }]);
|
|
});
|
|
|
|
for (const row of [0, 4]) {
|
|
it(`ignores hover and clicks visible select-list row ${row} after scrolling`, () => {
|
|
const list = new SelectList(
|
|
Array.from({ length: 12 }, (_, i) => ({ value: `item-${i}`, label: `Item ${i}` })),
|
|
5,
|
|
selectTheme,
|
|
);
|
|
const changes: string[] = [];
|
|
let selected: string | undefined;
|
|
list.onSelectionChange = (item) => changes.push(item.value);
|
|
list.onSelect = (item) => {
|
|
selected = item.value;
|
|
};
|
|
list.setSelectedIndex(5);
|
|
list.handleMouse({ ...mouse("wheel", 1, row), wheelDelta: 1 });
|
|
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
|
|
assert.deepStrictEqual(changes, ["item-6"]);
|
|
const before = list.render(80);
|
|
assert.match(before[row], new RegExp(`Item ${4 + row}$`));
|
|
|
|
for (const y of [0, 1, 2, 3, 4, row]) {
|
|
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y), button: "none" }), undefined);
|
|
assert.deepStrictEqual(list.render(80), before);
|
|
}
|
|
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
|
|
assert.deepStrictEqual(changes, ["item-6"]);
|
|
assert.strictEqual(selected, undefined);
|
|
|
|
list.handleMouse(mouse("press", 1, row));
|
|
list.render(80);
|
|
list.handleMouse(mouse("click", 1, row));
|
|
assert.strictEqual(selected, `item-${4 + row}`);
|
|
assert.deepStrictEqual(changes, ["item-6", `item-${4 + row}`]);
|
|
});
|
|
|
|
it(`ignores hover and clicks visible settings row ${row} after scrolling`, () => {
|
|
const changes: Array<{ id: string; value: string }> = [];
|
|
const list = new SettingsList(
|
|
Array.from({ length: 12 }, (_, i) => ({
|
|
id: `item-${i}`,
|
|
label: `Item ${i}`,
|
|
description: `Description ${i}`,
|
|
currentValue: "off",
|
|
values: ["off", "on"],
|
|
})),
|
|
5,
|
|
settingsTheme,
|
|
(id, value) => changes.push({ id, value }),
|
|
() => {},
|
|
{ enableSearch: true },
|
|
);
|
|
list.selectItem("item-5");
|
|
list.handleMouse({ ...mouse("wheel", 1, row + 2), wheelDelta: 1 });
|
|
const before = list.render(80);
|
|
assert.match(before[4], /^> Item 6/);
|
|
assert.match(before[row + 2], new RegExp(`Item ${4 + row} `));
|
|
|
|
for (const y of [0, 1, 2, 3, 4, row]) {
|
|
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y + 2), button: "none" }), undefined);
|
|
assert.deepStrictEqual(list.render(80), before);
|
|
}
|
|
assert.deepStrictEqual(changes, []);
|
|
|
|
list.handleMouse(mouse("press", 1, row + 2));
|
|
list.render(80);
|
|
list.handleMouse(mouse("click", 1, row + 2));
|
|
assert.deepStrictEqual(changes, [{ id: `item-${4 + row}`, value: "on" }]);
|
|
});
|
|
}
|
|
|
|
it("keeps a delegating overlay focused when its nested input is clicked", async () => {
|
|
const terminal = new VirtualTerminal(20, 4);
|
|
const tui = new TuiAltScreen(terminal);
|
|
const overlay = new InputOverlay();
|
|
overlay.input.setValue("hi");
|
|
tui.start();
|
|
tui.showOverlay(overlay, { anchor: "top-left", width: 20 });
|
|
await terminal.waitForRender();
|
|
|
|
terminal.sendInput("\x1b[<0;5;1M");
|
|
terminal.sendInput("\x1b[<0;5;1m");
|
|
terminal.sendInput("!");
|
|
await terminal.waitForRender();
|
|
|
|
assert.strictEqual(overlay.input.getValue(), "hi!");
|
|
assert.strictEqual(tui.getFocusedComponent(), overlay);
|
|
tui.stop();
|
|
});
|
|
|
|
it("keeps a settings list focused when a click in its submenu closes the submenu", async () => {
|
|
const terminal = new VirtualTerminal(30, 6);
|
|
const tui = new TuiAltScreen(terminal);
|
|
const changes: Array<{ id: string; value: string }> = [];
|
|
const list = new SettingsList(
|
|
[
|
|
{ id: "theme", label: "Theme", currentValue: "first", submenu: (_value, done) => new SubmenuHost(done) },
|
|
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
|
|
],
|
|
5,
|
|
settingsTheme,
|
|
(id, value) => changes.push({ id, value }),
|
|
() => {},
|
|
);
|
|
tui.addChild(list);
|
|
tui.setFocus(list);
|
|
tui.start();
|
|
await terminal.waitForRender();
|
|
|
|
terminal.sendInput("\r");
|
|
await terminal.waitForRender();
|
|
// Press and release on the submenu's second row selects it and closes the submenu.
|
|
terminal.sendInput("\x1b[<0;3;2M");
|
|
terminal.sendInput("\x1b[<0;3;2m");
|
|
await terminal.waitForRender();
|
|
assert.deepStrictEqual(changes, [{ id: "theme", value: "second" }]);
|
|
assert.strictEqual(tui.getFocusedComponent(), list);
|
|
|
|
// Keys reach the visible list again instead of the closed submenu.
|
|
terminal.sendInput("\x1b[B");
|
|
terminal.sendInput("\r");
|
|
await terminal.waitForRender();
|
|
assert.deepStrictEqual(changes, [
|
|
{ id: "theme", value: "second" },
|
|
{ id: "other", value: "on" },
|
|
]);
|
|
tui.stop();
|
|
});
|
|
|
|
it("positions and focuses the multiline editor through alternate-screen dispatch", async () => {
|
|
const terminal = new VirtualTerminal(20, 6);
|
|
const tui = new TuiAltScreen(terminal);
|
|
const editor = new Editor(tui, editorTheme);
|
|
editor.setText("hello");
|
|
tui.addChild(editor);
|
|
tui.start();
|
|
await terminal.waitForRender();
|
|
|
|
terminal.sendInput("\x1b[<0;3;2M");
|
|
terminal.sendInput("\x1b[<0;3;2m");
|
|
terminal.sendInput("X");
|
|
await terminal.waitForRender();
|
|
|
|
assert.strictEqual(editor.getText(), "heXllo");
|
|
assert.strictEqual(tui.getFocusedComponent(), editor);
|
|
tui.stop();
|
|
});
|
|
|
|
it("selects and copies editor text on drag instead of moving the cursor", async () => {
|
|
const terminal = new VirtualTerminal(20, 6);
|
|
const copied: string[] = [];
|
|
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
|
copySelection: async (text) => {
|
|
copied.push(text);
|
|
return true;
|
|
},
|
|
});
|
|
const editor = new Editor(tui, editorTheme);
|
|
editor.setText("hello world");
|
|
tui.addChild(editor);
|
|
tui.start();
|
|
await terminal.waitForRender();
|
|
const cursorBefore = editor.getCursor();
|
|
|
|
terminal.sendInput("\x1b[<0;1;2M");
|
|
terminal.sendInput("\x1b[<32;5;2M");
|
|
terminal.sendInput("\x1b[<0;5;2m");
|
|
await terminal.waitForRender();
|
|
|
|
assert.deepStrictEqual(copied, ["hello"]);
|
|
assert.deepStrictEqual(editor.getCursor(), cursorBefore);
|
|
tui.stop();
|
|
});
|
|
});
|