mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
Release v0.8.0
This commit is contained in:
parent
cc88095140
commit
85adcf22bf
48 changed files with 1530 additions and 608 deletions
|
|
@ -2,6 +2,7 @@
|
|||
* Simple chat interface demo using tui.ts
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { Loader } from "../src/components/loader.js";
|
||||
|
|
@ -9,6 +10,7 @@ import { Markdown } from "../src/components/markdown.js";
|
|||
import { Text } from "../src/components/text.js";
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
import { TUI } from "../src/tui.js";
|
||||
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
|
||||
|
||||
// Create terminal
|
||||
const terminal = new ProcessTerminal();
|
||||
|
|
@ -22,7 +24,7 @@ tui.addChild(
|
|||
);
|
||||
|
||||
// Create editor with autocomplete
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
// Set up autocomplete provider with slash commands and file completion
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
|
|
@ -78,12 +80,17 @@ editor.onSubmit = (value: string) => {
|
|||
isResponding = true;
|
||||
editor.disableSubmit = true;
|
||||
|
||||
const userMessage = new Markdown(value, 1, 1, { bgColor: "#343541" });
|
||||
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
|
||||
|
||||
const children = tui.children;
|
||||
children.splice(children.length - 1, 0, userMessage);
|
||||
|
||||
const loader = new Loader(tui, "Thinking...");
|
||||
const loader = new Loader(
|
||||
tui,
|
||||
(s) => chalk.cyan(s),
|
||||
(s) => chalk.dim(s),
|
||||
"Thinking...",
|
||||
);
|
||||
children.splice(children.length - 1, 0, loader);
|
||||
|
||||
tui.requestRender();
|
||||
|
|
@ -105,7 +112,7 @@ editor.onSubmit = (value: string) => {
|
|||
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
|
||||
|
||||
// Add assistant message with no background (transparent)
|
||||
const botMessage = new Markdown(randomResponse);
|
||||
const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme);
|
||||
children.splice(children.length - 1, 0, botMessage);
|
||||
|
||||
// Re-enable submit
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { defaultEditorTheme } from "./test-themes.js";
|
||||
|
||||
describe("Editor component", () => {
|
||||
describe("Unicode text editing behavior", () => {
|
||||
it("inserts mixed ASCII, umlauts, and emojis as literal text", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("H");
|
||||
editor.handleInput("e");
|
||||
|
|
@ -24,7 +25,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
|
|
@ -38,7 +39,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("deletes multi-code-unit emojis with repeated Backspace", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("😀");
|
||||
editor.handleInput("👍");
|
||||
|
|
@ -52,7 +53,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("inserts characters at the correct position after cursor movement over umlauts", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
|
|
@ -70,7 +71,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("moves cursor in code units across multi-code-unit emojis before insertion", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("😀");
|
||||
editor.handleInput("👍");
|
||||
|
|
@ -92,7 +93,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("preserves umlauts across line breaks", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
|
|
@ -107,7 +108,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("replaces the entire document with unicode text via setText (paste simulation)", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
// Simulate bracketed paste / programmatic replacement
|
||||
editor.setText("Hällö Wörld! 😀 äöüÄÖÜß");
|
||||
|
|
@ -117,7 +118,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("a");
|
||||
editor.handleInput("b");
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ class KeyLogger implements Component {
|
|||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import chalk from "chalk";
|
||||
import { Markdown } from "../src/components/markdown.js";
|
||||
import { defaultMarkdownTheme } from "./test-themes.js";
|
||||
|
||||
describe("Markdown component", () => {
|
||||
describe("Nested lists", () => {
|
||||
|
|
@ -12,6 +14,7 @@ describe("Markdown component", () => {
|
|||
- Item 2`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -37,6 +40,7 @@ describe("Markdown component", () => {
|
|||
- Level 4`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -57,6 +61,7 @@ describe("Markdown component", () => {
|
|||
2. Second`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -77,6 +82,7 @@ describe("Markdown component", () => {
|
|||
- More nested`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -97,6 +103,7 @@ describe("Markdown component", () => {
|
|||
| Bob | 25 |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -120,6 +127,7 @@ describe("Markdown component", () => {
|
|||
| Long text | Middle | End |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -141,6 +149,7 @@ describe("Markdown component", () => {
|
|||
| B | Short |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -168,6 +177,7 @@ describe("Markdown component", () => {
|
|||
| A | B |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -187,10 +197,16 @@ describe("Markdown component", () => {
|
|||
describe("Pre-styled text (thinking traces)", () => {
|
||||
it("should preserve gray italic styling after inline code", () => {
|
||||
// This replicates how thinking content is rendered in assistant-message.ts
|
||||
const markdown = new Markdown("This is thinking with `inline code` and more text after", 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
});
|
||||
const markdown = new Markdown(
|
||||
"This is thinking with `inline code` and more text after",
|
||||
1,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
{
|
||||
color: (text) => chalk.gray(text),
|
||||
italic: true,
|
||||
},
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
|
@ -208,10 +224,16 @@ describe("Markdown component", () => {
|
|||
});
|
||||
|
||||
it("should preserve gray italic styling after bold text", () => {
|
||||
const markdown = new Markdown("This is thinking with **bold text** and more after", 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
});
|
||||
const markdown = new Markdown(
|
||||
"This is thinking with **bold text** and more after",
|
||||
1,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
{
|
||||
color: (text) => chalk.gray(text),
|
||||
italic: true,
|
||||
},
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
|
@ -236,6 +258,7 @@ describe("Markdown component", () => {
|
|||
"This is text with <thinking>hidden content</thinking> that should be visible",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -250,7 +273,7 @@ describe("Markdown component", () => {
|
|||
});
|
||||
|
||||
it("should render HTML tags in code blocks correctly", () => {
|
||||
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0);
|
||||
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
|
|
|
|||
36
packages/tui/test/test-themes.ts
Normal file
36
packages/tui/test/test-themes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Default themes for TUI tests using chalk
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js";
|
||||
|
||||
export const defaultSelectListTheme: SelectListTheme = {
|
||||
selectedPrefix: (text: string) => chalk.blue(text),
|
||||
selectedText: (text: string) => chalk.bold(text),
|
||||
description: (text: string) => chalk.dim(text),
|
||||
scrollInfo: (text: string) => chalk.dim(text),
|
||||
noMatch: (text: string) => chalk.dim(text),
|
||||
};
|
||||
|
||||
export const defaultMarkdownTheme: MarkdownTheme = {
|
||||
heading: (text: string) => chalk.bold.cyan(text),
|
||||
link: (text: string) => chalk.blue(text),
|
||||
linkUrl: (text: string) => chalk.dim(text),
|
||||
code: (text: string) => chalk.yellow(text),
|
||||
codeBlock: (text: string) => chalk.green(text),
|
||||
codeBlockBorder: (text: string) => chalk.dim(text),
|
||||
quote: (text: string) => chalk.italic(text),
|
||||
quoteBorder: (text: string) => chalk.dim(text),
|
||||
hr: (text: string) => chalk.dim(text),
|
||||
listBullet: (text: string) => chalk.cyan(text),
|
||||
bold: (text: string) => chalk.bold(text),
|
||||
italic: (text: string) => chalk.italic(text),
|
||||
strikethrough: (text: string) => chalk.strikethrough(text),
|
||||
underline: (text: string) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const defaultEditorTheme: EditorTheme = {
|
||||
borderColor: (text: string) => chalk.dim(text),
|
||||
selectList: defaultSelectListTheme,
|
||||
};
|
||||
126
packages/tui/test/truncated-text.test.ts
Normal file
126
packages/tui/test/truncated-text.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import chalk from "chalk";
|
||||
import { TruncatedText } from "../src/components/truncated-text.js";
|
||||
import { visibleWidth } from "../src/utils.js";
|
||||
|
||||
describe("TruncatedText component", () => {
|
||||
it("pads output lines to exactly match width", () => {
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(50);
|
||||
|
||||
// Should have exactly one content line (no vertical padding)
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Line should be exactly 50 visible characters
|
||||
const visibleLen = visibleWidth(lines[0]);
|
||||
assert.strictEqual(visibleLen, 50);
|
||||
});
|
||||
|
||||
it("pads output with vertical padding lines to width", () => {
|
||||
const text = new TruncatedText("Hello", 0, 2);
|
||||
const lines = text.render(40);
|
||||
|
||||
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
|
||||
assert.strictEqual(lines.length, 5);
|
||||
|
||||
// All lines should be exactly 40 characters
|
||||
for (const line of lines) {
|
||||
assert.strictEqual(visibleWidth(line), 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long text and pads to width", () => {
|
||||
const longText = "This is a very long piece of text that will definitely exceed the available width";
|
||||
const text = new TruncatedText(longText, 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 30 characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("preserves ANSI codes in output and pads correctly", () => {
|
||||
const styledText = chalk.red("Hello") + " " + chalk.blue("world");
|
||||
const text = new TruncatedText(styledText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 40 visible characters (ANSI codes don't count)
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should preserve the color codes
|
||||
assert.ok(lines[0].includes("\x1b["));
|
||||
});
|
||||
|
||||
it("truncates styled text and adds reset code before ellipsis", () => {
|
||||
const longStyledText = chalk.red("This is a very long red text that will be truncated");
|
||||
const text = new TruncatedText(longStyledText, 1, 0);
|
||||
const lines = text.render(20);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 20 visible characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 20);
|
||||
|
||||
// Should contain reset code before ellipsis
|
||||
assert.ok(lines[0].includes("\x1b[0m..."));
|
||||
});
|
||||
|
||||
it("handles text that fits exactly", () => {
|
||||
// With paddingX=1, available width is 30-2=28
|
||||
// "Hello world" is 11 chars, fits comfortably
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should NOT contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(!stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
const text = new TruncatedText("", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
});
|
||||
|
||||
it("stops at newline and only shows first line", () => {
|
||||
const multilineText = "First line\nSecond line\nThird line";
|
||||
const text = new TruncatedText(multilineText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should only contain "First line"
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
|
||||
assert.ok(stripped.includes("First line"));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
assert.ok(!stripped.includes("Third line"));
|
||||
});
|
||||
|
||||
it("truncates first line even with newlines in text", () => {
|
||||
const longMultilineText = "This is a very long first line that needs truncation\nSecond line";
|
||||
const text = new TruncatedText(longMultilineText, 1, 0);
|
||||
const lines = text.render(25);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 25);
|
||||
|
||||
// Should contain ellipsis and not second line
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
});
|
||||
});
|
||||
|
|
@ -65,22 +65,24 @@ describe("wrapTextWithAnsi", () => {
|
|||
});
|
||||
|
||||
describe("applyBackgroundToLine", () => {
|
||||
const greenBg = (text: string) => chalk.bgGreen(text);
|
||||
|
||||
it("applies background to plain text and pads to width", () => {
|
||||
const line = "hello";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
const result = applyBackgroundToLine(line, 20, greenBg);
|
||||
|
||||
// Should be exactly 20 visible chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.strictEqual(stripped.length, 20);
|
||||
|
||||
// Should have background codes
|
||||
assert.ok(result.includes("\x1b[48;2;0;255;0m"));
|
||||
assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m"));
|
||||
assert.ok(result.includes("\x1b[49m"));
|
||||
});
|
||||
|
||||
it("handles text with ANSI codes and resets", () => {
|
||||
const line = chalk.bold("hello") + " world";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
const result = applyBackgroundToLine(line, 20, greenBg);
|
||||
|
||||
// Should be exactly 20 visible chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
|
@ -90,13 +92,13 @@ describe("applyBackgroundToLine", () => {
|
|||
assert.ok(result.includes("\x1b[1m"));
|
||||
|
||||
// Should have background throughout (even after resets)
|
||||
assert.ok(result.includes("\x1b[48;2;0;255;0m"));
|
||||
assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m"));
|
||||
});
|
||||
|
||||
it("handles text with 0m resets by reapplying background", () => {
|
||||
// Simulate: bold text + reset + normal text
|
||||
const line = "\x1b[1mhello\x1b[0m world";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
const result = applyBackgroundToLine(line, 20, greenBg);
|
||||
|
||||
// Should NOT have black cells (spaces without background)
|
||||
// Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue