Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,083 changes: 2,356 additions & 1,727 deletions package-lock.json

Large diffs are not rendered by default.

52 changes: 26 additions & 26 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,42 +52,42 @@
"access": "public"
},
"dependencies": {
"commander": "^14.0.0",
"js-yaml": "^4.1.0",
"boxen": "^7.1.1",
"chalk": "^5.3.0",
"cli-table3": "^0.6.3",
"ora": "^8.1.0",
"boxen": "^8.0.1",
"chalk": "^5.6.2",
"cli-markdown": "^3.5.1",
"commander": "^14.0.1",
"enquirer": "^2.4.1",
"fuse.js": "^7.0.0"
"fuse.js": "^7.1.0",
"js-yaml": "^4.1.0",
"ora": "^8.2.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.1.1",
"@commitlint/cli": "^19.4.0",
"@commitlint/config-conventional": "^19.4.0",
"@commitlint/cz-commitlint": "^19.4.0",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@commitlint/cz-commitlint": "^19.8.1",
"@eslint/js": "^9.35.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^11.1.0",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^10.0.3",
"@semantic-release/npm": "^11.0.2",
"@semantic-release/release-notes-generator": "^12.1.0",
"@semantic-release/github": "^11.0.6",
"@semantic-release/npm": "^12.0.2",
"@semantic-release/release-notes-generator": "^14.1.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.3.1",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"commitizen": "^4.3.0",
"@types/node": "^24.4.0",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"@vitest/coverage-v8": "^3.2.4",
"commitizen": "^4.3.1",
"eslint": "^9.35.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^15.9.0",
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"prettier": "^3.3.3",
"semantic-release": "^23.1.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^16.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.6",
"prettier": "^3.6.2",
"semantic-release": "^24.2.8",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"vitest": "^2.1.1"
"vitest": "^3.2.4"
},
"lint-staged": {
"src/**/*.{ts,js,mjs,cjs}": [
Expand Down
12 changes: 10 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,16 @@ export function buildProgram() {
...chatOptions,
onFirstToken: () => {
if (!stopped) {
spinner.stop();
stopped = true;
// Change spinner text to "Answer" before clearing
spinner.text = 'Answer';
// Give a brief moment to show the "Answer" text
setTimeout(() => {
spinner.clear();
spinner.stop();
// Add blank line for separation
process.stdout.write('\n');
stopped = true;
}, 200);
}
},
onDone: () => {
Expand Down
22 changes: 20 additions & 2 deletions src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,16 @@ export async function startRepl(opts: ReplOptions) {
stream: true,
onFirstToken: () => {
if (!stopped) {
spinner.stop();
stopped = true;
// Change spinner text to "Answer" before clearing
spinner.text = 'Answer';
// Give a brief moment to show the "Answer" text
setTimeout(() => {
spinner.clear();
spinner.stop();
// Add blank line for separation
process.stdout.write('\n');
stopped = true;
}, 200);
}
},
onDone: () => {
Expand All @@ -234,6 +242,12 @@ export async function startRepl(opts: ReplOptions) {
[...history]
);
process.stdout.write('\n');

// Add the assistant's response to history
// Note: We can't easily capture the full response text from streaming
// so we'll add a placeholder that gets updated after the response
const assistantMsg = { role: 'assistant' as const, content: '[STREAMING_RESPONSE]' };
history.push(assistantMsg);
} finally {
if (!stopped) {
spinner.stop();
Expand All @@ -256,6 +270,10 @@ export async function startRepl(opts: ReplOptions) {
const pretty = renderText(result.text, { format, streaming: false });
process.stdout.write(pretty + '\n');

// Add the assistant's response to history
const assistantMsg = { role: 'assistant' as const, content: result.text };
history.push(assistantMsg);

// Display usage information if available
if (result.usage) {
const usage = result.usage;
Expand Down
86 changes: 23 additions & 63 deletions src/shared/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// @ts-ignore - cli-markdown doesn't have types
import cliMarkdown from 'cli-markdown';

export type OutputFormat = 'auto' | 'plain' | 'md';

// Very small markdown → ANSI formatter (headings, lists, code)
// Markdown → ANSI formatter using cli-markdown
export function renderText(
input: string,
opts: { format: OutputFormat; streaming: boolean }
Expand All @@ -12,68 +15,25 @@ export function renderText(
return input;
}

export function toAnsiMarkdown(md: string): string {
const lines = md.split(/\r?\n/);
const out: string[] = [];
let inFence = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (/^```/.test(line.trim())) {
inFence = !inFence;
out.push(ansiDim(''));
continue;
}
if (inFence) {
out.push(ansiCyan(' ' + line));
continue;
}
// Headings
if (/^###\s+/.test(line)) {
out.push(ansiBold(line.replace(/^###\s+/, '').trim()));
continue;
}
if (/^##\s+/.test(line)) {
out.push(ansiBold(line.replace(/^##\s+/, '').trim()));
continue;
}
if (/^#\s+/.test(line)) {
out.push(ansiBold(line.replace(/^#\s+/, '').trim()));
continue;
}
// Lists
if (/^\s*[-*+]\s+/.test(line)) {
line = line.replace(/^\s*[-*+]\s+/, ' • ');
// fall through for inline formatting below
export function toAnsiMarkdown(mdText: string): string {
try {
// Set FORCE_COLOR to ensure colors are output even in non-TTY environments
const originalForceColor = process.env.FORCE_COLOR;
process.env.FORCE_COLOR = '1';

const result = cliMarkdown(mdText);

// Restore original value
if (originalForceColor === undefined) {
delete process.env.FORCE_COLOR;
} else {
process.env.FORCE_COLOR = originalForceColor;
}
// Inline code: `code`
line = line.replace(/`([^`]+)`/g, (_, m1: string) => ansiDim(m1));
// Inline bold: **text** or __text__
line = line.replace(/\*\*([^*]+)\*\*/g, (_, m1: string) => ansiBold(m1));
line = line.replace(/__([^_]+)__/g, (_, m1: string) => ansiBold(m1));
// Inline italic: *text* or _text_ (basic, non-greedy, ignore escaped)
line = line.replace(
/(^|[^\\])\*([^*\s][^*]*?)\*(?!\*)/g,
(_m, p1: string, m1: string) => p1 + ansiItalic(m1)
);
line = line.replace(
/(^|[^\\])_([^_\s][^_]*)_(?!_)/g,
(_m, p1: string, m1: string) => p1 + ansiItalic(m1)
);
out.push(line);
}
return out.join('\n');
}

// Minimal ANSI helpers
function ansiBold(s: string) {
return '\x1b[1m' + s + '\x1b[22m';
}
function ansiDim(s: string) {
return '\x1b[2m' + s + '\x1b[22m';
}
function ansiCyan(s: string) {
return '\x1b[36m' + s + '\x1b[39m';
}
function ansiItalic(s: string) {
return '\x1b[3m' + s + '\x1b[23m';
return result;
} catch (error) {
// Fallback to plain text if markdown parsing fails
console.warn('Markdown parsing failed, falling back to plain text:', error);
return mdText;
}
}
10 changes: 10 additions & 0 deletions src/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ export function showSpinner(label: string) {
return spinner;
}

export function showAnswerSpinner() {
const enabled = isColorSupported();
const spinner = ora({
text: palette.dim('Answer'),
isEnabled: enabled,
stream: process.stderr as any,
});
return spinner;
}

export function warnBox(text: string): string {
// Emphasize with red text and a clear border; remains readable without color
const body = palette.err(text);
Expand Down
4 changes: 4 additions & 0 deletions src/types/js-yaml.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module 'js-yaml' {
export function load(str: string, options?: any): any;
export function dump(obj: any, options?: any): string;
}
36 changes: 29 additions & 7 deletions tests/format.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
import { describe, it, expect } from 'vitest';
import { renderText } from '../src/shared/format.js';

describe('markdown rendering (ANSI)', () => {
it('renders headings, lists, and inline code with ANSI', () => {
describe('markdown rendering', () => {
it('renders headings, lists, and inline code', () => {
const src = '# Title\n\n- item\n\n`code`';
const out = renderText(src, { format: 'md', streaming: false });
// Bold heading (\x1b[1m ... \x1b[22m)
expect(out).toMatch(/\x1b\[1m.*Title.*\x1b\[22m/);
// Bullet replacement

// Test basic markdown structure
expect(out).toContain('Title');
expect(out).toContain('• item');
// Inline code dim
expect(out).toMatch(/\x1b\[2mcode\x1b\[22m/);
expect(out).toContain('code');

// Test that it's not just plain text (should have some formatting)
expect(out).not.toBe(src);

// Test that cli-markdown processed the content (adds spacing and formatting)
expect(out).toContain('\n # Title\n');
expect(out).toContain('\n • item\n');
});

it('handles plain text format', () => {
const src = '# Title\n\n- item\n\n`code`';
const out = renderText(src, { format: 'plain', streaming: false });

// Plain format should return unchanged
expect(out).toBe(src);
});

it('handles streaming mode', () => {
const src = '# Title\n\n- item\n\n`code`';
const out = renderText(src, { format: 'md', streaming: true });

// Streaming mode should return unchanged
expect(out).toBe(src);
});
});