Compare commits

..

No commits in common. "main" and "feat/tts-input" have entirely different histories.

14 changed files with 245 additions and 325 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View file

@ -1,36 +1,26 @@
# lmc
TypeScript CLI tool for streaming LLM chat with inline markdown rendering.
Bun CLI tool for streaming LLM chat with inline markdown rendering.
## Commands
```
pnpm run dev # dev run (rolldown bundle + node)
pnpm run check # biome lint + format + organize imports + typecheck
pnpm run build # check + bundle index.ts and tts.ts (minified ESM)
pnpm run build:bin # check + bundle with esbuild (CJS) + build SEA binaries
pnpm run clean # remove dist/
bun run dev # dev run
bun run prod # run bundled output
bun run check # biome lint + format + organize imports
```
## Architecture
- `src/index.ts` — LMC CLI entry point, orchestration
- `src/tts.ts` — TTSC CLI entry point (text-to-speech, Kokoro pipeline via `uv run`)
- `src/config.ts``initConfig()`, `loadConfig()`: manages `~/.lmc_config.json`
- `src/conversation.ts``Conversation` class: load/save to `~/.lmc_convo.json`
- `src/errors.ts``handleTopLevel()`: TTY-aware error wrapping
- `src/prompts.ts``commitDiff()`, `reviewFile()`, `reviewDiff()`, `file()`: prompt builders
- `index.ts` — CLI entry point, orchestration
- `src/render/index.ts``Renderer` class: streams LLM response, buffers markdown, renders to stdout/stderr
- `src/render/markdown.ts``renderMarkdown()`: markdown via `marked` + Shiki + `@cliffy/table`
- `src/render/codeToANSI.ts` — Shiki token output to ANSI escape sequences
- `src/render/markdown.ts``renderMarkdown()`: syntax-highlighted markdown via `@comark/ansi` + Shiki
## Gotchas
- **Node.js** — Use modern Node APIs (`node:fs/promises`, `node:stream/consumers`, etc.).
- **Bun only** — not Node. Use `Bun.file()`, `Bun.argv`, `Bun.sleep()`.
- **No tests** — none exist.
- **Biome config**: double quotes, space indent, auto-import-organize on. `pnpm run check` also runs `tsc --noEmit`.
- **Build tools**: rolldown for ESM bundles, esbuild for CJS binaries, `node --build-sea` for SEA single-executable apps.
- **TTS**: requires Python with `uv` — Kokoro pipeline lives in `tts/`.
- **Biome config**: double quotes, space indent, auto-import-organize on. Run `bun run check` before committing.
## Style

84
index.ts Normal file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env node
import util from "node:util";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText } from "ai";
import { initConfig, loadConfig } from "./src/config";
import { Conversation } from "./src/conversation";
import Renderer from "./src/render";
const { values: args, positionals } = util.parseArgs({
args: process.argv.slice(2),
options: {
model: { type: "string" },
m: { type: "string" },
new: { type: "boolean" },
n: { type: "boolean" },
config: { type: "boolean" },
c: { type: "boolean" },
plain: { type: "boolean" },
plainOut: { type: "boolean" },
plainErr: { type: "boolean" },
v: { type: "boolean" },
},
strict: true,
allowPositionals: true,
});
async function main() {
const newConvo = args.new ?? args.n;
const plainOut = args.plainOut ?? args.plain ?? !process.stdout.isTTY;
const plainErr = args.plainErr ?? args.plain ?? !process.stderr.isTTY;
const modelFlag = args.model ?? args.m;
const configFlag = args.config ?? args.c;
if (args.v) {
console.log("0.0.0");
return;
}
const config = await loadConfig(configFlag);
if (configFlag || !config) {
await initConfig(config);
return;
}
const model = modelFlag ?? config.model;
const renderer = new Renderer(plainOut, plainErr);
const provider = createOpenAICompatible({
name: "llama.cpp",
apiKey: "local",
baseURL: config.baseURL,
includeUsage: true, // Include usage information in streaming responses
});
const convo = new Conversation(model);
if (!newConvo) {
await convo.load();
if (modelFlag) convo.model = modelFlag;
}
const prompt = positionals.join(" ") || "Introduce yourself.";
convo.messages.push({ role: "user", content: prompt });
const { fullStream, text, totalUsage } = streamText({
model: provider(convo.model),
messages: convo.messages,
});
await renderer.renderStream(fullStream);
convo.messages.push({ role: "assistant", content: await text });
await convo.save();
await renderer.renderStats(totalUsage);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,5 +1,6 @@
{
"name": "lmc",
"module": "index.ts",
"type": "module",
"private": true,
"bin": {
@ -7,35 +8,35 @@
"ttsc": "dist/tts.js"
},
"scripts": {
"dev": "pnpm run bundle src/index.ts >/dev/null && node dist/index.js",
"dev": "pnpm run bundle index.ts &>/dev/null && node dist/index.js",
"check": "biome check --write && pnpm run tc",
"clean": "rm -rf dist",
"build": "pnpm run check && pnpm run bundle --minify src/index.ts && pnpm run bundle --minify src/tts.ts",
"build:bin": "pnpm run check && pnpm run bundle:bin --minify src/index.ts && pnpm run bundle:bin --minify src/tts.ts && pnpm run bin",
"build": "pnpm run check && pnpm run bundle --minify index.ts && pnpm run bundle --minify tts.ts",
"build:bin": "pnpm run check && pnpm run bundle:bin --minify index.ts && pnpm run bundle:bin --minify tts.ts && pnpm run bin",
"bundle": "rolldown --dir=dist --platform=node --transform.target=node22 --format=esm",
"bundle:bin": "esbuild --bundle --outdir=dist --platform=node --target=node26 --format=cjs",
"bin": "node --build-sea sea-config.json && node --build-sea sea-config-tts.json",
"tc": "tsc --noEmit --incremental"
},
"devDependencies": {
"@ai-sdk/openai-compatible": "^2.0.50",
"@ai-sdk/openai-compatible": "^2.0.41",
"@biomejs/biome": "2.4.12",
"@clack/prompts": "^1.5.1",
"@cliffy/table": "jsr:^1.2.1",
"@clack/prompts": "^1.3.0",
"@cliffy/table": "npm:@jsr/cliffy__table",
"@mozilla/readability": "^0.6.0",
"@types/node": "^25.9.3",
"ai": "^6.0.206",
"@types/node": "^25.9.2",
"ai": "^6.0.161",
"ansi-styles": "^6.2.3",
"chalk": "^5.6.2",
"esbuild": "^0.28.1",
"execa": "^9.6.1",
"happy-dom": "^20.10.3",
"marked": "^18.0.5",
"happy-dom": "^20.10.2",
"marked": "^18.0.4",
"rolldown": "^1.1.1",
"shiki": "^4.2.0",
"shiki": "^4.0.2",
"string-width": "^8.2.1",
"terminal-link": "^5.0.0",
"typescript": "^5.9.3",
"typescript": "^5",
"wrap-ansi": "^10.0.0"
}
}

199
pnpm-lock.yaml generated
View file

@ -9,26 +9,26 @@ importers:
.:
devDependencies:
'@ai-sdk/openai-compatible':
specifier: ^2.0.50
version: 2.0.50(zod@4.4.3)
specifier: ^2.0.41
version: 2.0.48(zod@4.4.3)
'@biomejs/biome':
specifier: 2.4.12
version: 2.4.12
'@clack/prompts':
specifier: ^1.5.1
version: 1.5.1
specifier: ^1.3.0
version: 1.4.0
'@cliffy/table':
specifier: jsr:^1.2.1
specifier: npm:@jsr/cliffy__table
version: '@jsr/cliffy__table@1.2.1'
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
'@types/node':
specifier: ^25.9.3
version: 25.9.3
specifier: ^25.9.2
version: 25.9.2
ai:
specifier: ^6.0.206
version: 6.0.206(zod@4.4.3)
specifier: ^6.0.161
version: 6.0.191(zod@4.4.3)
ansi-styles:
specifier: ^6.2.3
version: 6.2.3
@ -42,17 +42,17 @@ importers:
specifier: ^9.6.1
version: 9.6.1
happy-dom:
specifier: ^20.10.3
version: 20.10.3
specifier: ^20.10.2
version: 20.10.2
marked:
specifier: ^18.0.5
version: 18.0.5
specifier: ^18.0.4
version: 18.0.4
rolldown:
specifier: ^1.1.1
version: 1.1.1
shiki:
specifier: ^4.2.0
version: 4.2.0
specifier: ^4.0.2
version: 4.1.0
string-width:
specifier: ^8.2.1
version: 8.2.1
@ -60,7 +60,7 @@ importers:
specifier: ^5.0.0
version: 5.0.0
typescript:
specifier: ^5.9.3
specifier: ^5
version: 5.9.3
wrap-ansi:
specifier: ^10.0.0
@ -68,20 +68,20 @@ importers:
packages:
'@ai-sdk/gateway@3.0.132':
resolution: {integrity: sha512-sFZFGk6aVSNKmgDrb14efaAbvM71AB3UUvaTsU+bvhWP9jrkRrwix/jFX8IoOAJk0/X7Xf7p3m0J2O2G6ljZbA==}
'@ai-sdk/gateway@3.0.120':
resolution: {integrity: sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai-compatible@2.0.50':
resolution: {integrity: sha512-HyuxddF2Yv5G8qxK/0uksAINjQ4h6TpwOqHuqzsCM0u78/JWAW2OXcIplQeB44PIAORgPjbMzrw9DhnPYHMskA==}
'@ai-sdk/openai-compatible@2.0.48':
resolution: {integrity: sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.29':
resolution: {integrity: sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==}
'@ai-sdk/provider-utils@4.0.27':
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@ -147,12 +147,12 @@ packages:
cpu: [x64]
os: [win32]
'@clack/core@1.4.1':
resolution: {integrity: sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==}
'@clack/core@1.3.1':
resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==}
engines: {node: '>= 20.12.0'}
'@clack/prompts@1.5.1':
resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==}
'@clack/prompts@1.4.0':
resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==}
engines: {node: '>= 20.12.0'}
'@emnapi/core@1.11.0':
@ -444,32 +444,32 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shikijs/core@4.2.0':
resolution: {integrity: sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==}
'@shikijs/core@4.1.0':
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.2.0':
resolution: {integrity: sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==}
'@shikijs/engine-javascript@4.1.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.2.0':
resolution: {integrity: sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==}
'@shikijs/engine-oniguruma@4.1.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
engines: {node: '>=20'}
'@shikijs/langs@4.2.0':
resolution: {integrity: sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==}
'@shikijs/langs@4.1.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
engines: {node: '>=20'}
'@shikijs/primitive@4.2.0':
resolution: {integrity: sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==}
'@shikijs/primitive@4.1.0':
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
engines: {node: '>=20'}
'@shikijs/themes@4.2.0':
resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==}
'@shikijs/themes@4.1.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
engines: {node: '>=20'}
'@shikijs/types@4.2.0':
resolution: {integrity: sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==}
'@shikijs/types@4.1.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
@ -491,8 +491,11 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/node@25.9.2':
resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -510,8 +513,8 @@ packages:
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
ai@6.0.206:
resolution: {integrity: sha512-hVdB5PozMMxmkPBfbCxkFOn0eVeCMi/imkfPvk65cxL4O8oIrvjUxDUX8dGkdkWG1No+R7e7D9ti2DHO61Z4YQ==}
ai@6.0.191:
resolution: {integrity: sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@ -572,8 +575,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
execa@9.6.1:
@ -601,8 +604,8 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
happy-dom@20.10.3:
resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==}
happy-dom@20.10.2:
resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==}
engines: {node: '>=20.0.0'}
has-flag@5.0.1:
@ -640,8 +643,8 @@ packages:
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
marked@18.0.4:
resolution: {integrity: sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==}
engines: {node: '>= 20'}
hasBin: true
@ -689,8 +692,8 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@ -714,8 +717,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@4.2.0:
resolution: {integrity: sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==}
shiki@4.1.0:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
engines: {node: '>=20'}
signal-exit@4.1.0:
@ -831,24 +834,24 @@ packages:
snapshots:
'@ai-sdk/gateway@3.0.132(zod@4.4.3)':
'@ai-sdk/gateway@3.0.120(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@vercel/oidc': 3.2.0
zod: 4.4.3
'@ai-sdk/openai-compatible@2.0.50(zod@4.4.3)':
'@ai-sdk/openai-compatible@2.0.48(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/provider-utils@4.0.29(zod@4.4.3)':
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.1.0
eventsource-parser: 3.0.8
zod: 4.4.3
'@ai-sdk/provider@3.0.10':
@ -890,14 +893,14 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.12':
optional: true
'@clack/core@1.4.1':
'@clack/core@1.3.1':
dependencies:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@clack/prompts@1.5.1':
'@clack/prompts@1.4.0':
dependencies:
'@clack/core': 1.4.1
'@clack/core': 1.3.1
fast-string-width: 3.0.2
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
@ -1068,40 +1071,40 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@4.2.0':
'@shikijs/core@4.1.0':
dependencies:
'@shikijs/primitive': 4.2.0
'@shikijs/types': 4.2.0
'@shikijs/primitive': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.2.0':
'@shikijs/engine-javascript@4.1.0':
dependencies:
'@shikijs/types': 4.2.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.2.0':
'@shikijs/engine-oniguruma@4.1.0':
dependencies:
'@shikijs/types': 4.2.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.2.0':
'@shikijs/langs@4.1.0':
dependencies:
'@shikijs/types': 4.2.0
'@shikijs/types': 4.1.0
'@shikijs/primitive@4.2.0':
'@shikijs/primitive@4.1.0':
dependencies:
'@shikijs/types': 4.2.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.2.0':
'@shikijs/themes@4.1.0':
dependencies:
'@shikijs/types': 4.2.0
'@shikijs/types': 4.1.0
'@shikijs/types@4.2.0':
'@shikijs/types@4.1.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@ -1125,7 +1128,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/node@25.9.3':
'@types/node@25.9.1':
dependencies:
undici-types: 7.24.6
'@types/node@25.9.2':
dependencies:
undici-types: 7.24.6
@ -1135,17 +1142,17 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.9.3
'@types/node': 25.9.1
'@ungap/structured-clone@1.3.1': {}
'@vercel/oidc@3.2.0': {}
ai@6.0.206(zod@4.4.3):
ai@6.0.191(zod@4.4.3):
dependencies:
'@ai-sdk/gateway': 3.0.132(zod@4.4.3)
'@ai-sdk/gateway': 3.0.120(zod@4.4.3)
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.29(zod@4.4.3)
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@opentelemetry/api': 1.9.1
zod: 4.4.3
@ -1159,7 +1166,7 @@ snapshots:
buffer-image-size@0.6.4:
dependencies:
'@types/node': 25.9.3
'@types/node': 25.9.1
ccount@2.0.1: {}
@ -1216,7 +1223,7 @@ snapshots:
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
eventsource-parser@3.1.0: {}
eventsource-parser@3.0.8: {}
execa@9.6.1:
dependencies:
@ -1254,9 +1261,9 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
happy-dom@20.10.3:
happy-dom@20.10.2:
dependencies:
'@types/node': 25.9.3
'@types/node': 25.9.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
@ -1278,7 +1285,7 @@ snapshots:
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.2.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
@ -1301,7 +1308,7 @@ snapshots:
json-schema@0.4.0: {}
marked@18.0.5: {}
marked@18.0.4: {}
mdast-util-to-hast@13.2.1:
dependencies:
@ -1355,7 +1362,7 @@ snapshots:
dependencies:
parse-ms: 4.0.0
property-information@7.2.0: {}
property-information@7.1.0: {}
regex-recursion@6.0.2:
dependencies:
@ -1394,14 +1401,14 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@4.2.0:
shiki@4.1.0:
dependencies:
'@shikijs/core': 4.2.0
'@shikijs/engine-javascript': 4.2.0
'@shikijs/engine-oniguruma': 4.2.0
'@shikijs/langs': 4.2.0
'@shikijs/themes': 4.2.0
'@shikijs/types': 4.2.0
'@shikijs/core': 4.1.0
'@shikijs/engine-javascript': 4.1.0
'@shikijs/engine-oniguruma': 4.1.0
'@shikijs/langs': 4.1.0
'@shikijs/themes': 4.1.0
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4

View file

@ -1,3 +1,2 @@
minimumReleaseAge: 4320
allowBuilds:
esbuild: true

View file

@ -1,27 +0,0 @@
import { chalkStderr } from "chalk";
import wrapAnsi from "wrap-ansi";
export interface ErrorLike {
message: string;
}
export const isErrorLike = (err: unknown): err is ErrorLike =>
typeof err === "object" &&
err !== null &&
"message" in err &&
typeof err.message === "string";
export function handleTopLevel(err: unknown) {
if (isErrorLike(err)) {
if (process.stderr.isTTY) {
const [columns] = process.stderr.getWindowSize();
console.error(wrapAnsi(chalkStderr.red(err.message), columns));
} else {
console.error(err.message);
}
} else {
console.error(String(err));
}
process.exit(1);
}

View file

@ -1,107 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import consumers from "node:stream/consumers";
import util from "node:util";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText } from "ai";
import { initConfig, loadConfig } from "./config";
import { Conversation } from "./conversation";
import { handleTopLevel } from "./errors";
import {
base,
commitDiff,
contextFence,
diffFence,
fileFence,
review,
} from "./prompts";
import Renderer from "./render";
async function main() {
const { values: args, positionals } = util.parseArgs({
args: process.argv.slice(2),
options: {
model: { type: "string", short: "m" },
new: { type: "boolean", short: "n" },
config: { type: "boolean", short: "c" },
plain: { type: "boolean" },
"plain-out": { type: "boolean" },
"plain-err": { type: "boolean" },
version: { type: "boolean", short: "v" },
"git-message": { type: "boolean", short: "g" },
review: { type: "boolean", short: "r" },
file: { type: "string", short: "f", multiple: true },
stdin: { type: "boolean", short: "i" },
},
strict: true,
allowPositionals: true,
});
if (args.version) {
console.log("0.0.0");
return;
}
const config = await loadConfig(args.config);
if (args.config || !config) {
await initConfig(config);
return;
}
const model = args.model ?? config.model;
const plainOut = args["plain-out"] ?? args.plain ?? !process.stdout.isTTY;
const plainErr = args["plain-err"] ?? args.plain ?? !process.stderr.isTTY;
const renderer = new Renderer(plainOut, plainErr);
const provider = createOpenAICompatible({
name: "llama.cpp",
apiKey: "local",
baseURL: config.baseURL,
includeUsage: true, // Include usage information in streaming responses
});
const convo = new Conversation(model);
if (!args.new) {
await convo.load();
if (args.model) convo.model = args.model;
}
const input = positionals.join(" ").trim();
let stdinText = "";
if (args.stdin) stdinText = await consumers.text(process.stdin);
const files = [];
for (const file of args.file ?? [])
files.push(fileFence(file, await fs.readFile(file, "utf8")));
let prompt = base(input, [
...files,
stdinText ? contextFence(stdinText) : "",
]);
if (args["git-message"]) {
prompt = commitDiff(stdinText, [...files]);
} else if (args.review) {
prompt = review([...files, stdinText ? diffFence(stdinText) : "", input]);
}
if (!prompt) throw new Error("No prompt given.");
convo.messages.push({ role: "user", content: prompt });
const { fullStream, text, totalUsage } = streamText({
model: provider(convo.model),
messages: convo.messages,
});
await renderer.renderStream(fullStream);
convo.messages.push({ role: "assistant", content: await text });
await convo.save();
await renderer.renderStats(totalUsage);
}
main().catch(handleTopLevel);

View file

@ -1,31 +0,0 @@
const blocks = (input?: string[]) =>
input?.map((block) => (block ? `${block}\n` : "")).join("") ?? "";
export const base = (input: string, extra?: string[]) =>
`${blocks(extra)}${input}`;
export const fileFence = (name: string, input: string) => `<file name="${name}">
${input}
</file>
`;
export const diffFence = (input: string) => `<diff>
${input}
</diff>`;
export const contextFence = (input: string) => `<context>
${input}
</context>`;
export const commitDiff = (
input: string,
extra?: string[],
) => `${blocks(extra)}${diffFence(input)}
Generate a conventional commit message for the included context. If you are unable to generate a reasonable option, ask for more context.`;
export const review = (input: string[]) => `${blocks(input)}
Review the included context for any potential bugs, best practices, performance issues, or security flaws.
Today's date is ${new Date().toISOString()}, your version info is likely out-of-date.
Let me know if you need any more context to complete your review.`;

View file

@ -51,7 +51,7 @@ export default class Renderer {
this.charsSinceNL = this.charsSinceNL - columns;
}
if (this.lineCount >= Math.max(rows - 4, 1)) {
if (this.lineCount >= Math.max(rows - 10, 1)) {
this.moveClearReset(-this.lineCount, pipe);
}
}
@ -84,13 +84,7 @@ export default class Renderer {
async renderStream(stream: AsyncIterableStream<TextStreamPart<ToolSet>>) {
this.resetCounts();
const spin = spinner({
withGuide: false,
indicator: "timer",
onCancel: () => {
process.exit(130);
},
});
const spin = spinner({ withGuide: false, indicator: "timer" });
if (!this.isPlain) {
spin.start("Waiting for response");
}

View file

@ -76,7 +76,6 @@ class LmcRenderer extends Renderer {
this.listitem(item, ordered ? num + index : undefined),
)
.join("\n");
return `\n${renderedItems}\n`;
}
@ -92,8 +91,7 @@ class LmcRenderer extends Renderer {
const item = `${marker} ${text}`;
return item
.split("\n")
.filter((line) => line.length > 0)
.map((line) => ` ${line}`)
.map((line) => (line.length > 0 ? ` ${line}` : ""))
.join("\n");
}

View file

@ -1,17 +1,20 @@
#!/usr/bin/env node
import events from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import readline from "node:readline";
import consumers from "node:stream/consumers";
import util from "node:util";
import { Readability } from "@mozilla/readability";
import chalk from "chalk";
import { ExecaError, execa } from "execa";
import { execa } from "execa";
import { Window } from "happy-dom";
import wrapAnsi from "wrap-ansi";
import { handleTopLevel } from "./errors";
class InputError extends Error {
override name = "InputError";
}
async function main() {
const start = Date.now();
@ -21,8 +24,6 @@ async function main() {
options: {
wait: { type: "boolean" },
w: { type: "boolean" },
pause: { type: "boolean" },
p: { type: "boolean" },
url: { type: "boolean" },
u: { type: "boolean" },
edit: { type: "boolean" },
@ -32,20 +33,27 @@ async function main() {
allowPositionals: true,
});
const waitAndCombine = args.wait ?? args.w;
const pausePlayer = args.pause ?? args.p;
const readURL = args.url ?? args.u;
const editContent = args.edit ?? args.e;
const wait = args.wait ?? args.w;
const url = args.url ?? args.u;
const edit = args.edit ?? args.e;
let input = positionals.join(" ").trim();
if (!input) input = (await consumers.text(process.stdin)).trim();
if (!input) {
throw new Error("No input was given. Use positional arguments or stdin.");
process.stdin.setEncoding("utf8");
process.stdin.on("data", (data) => {
input += data;
});
await events.once(process.stdin, "end");
}
if (readURL) {
if (!input) {
throw new InputError(
"No input was given. Use positional arguments or stdin.",
);
}
if (url) {
const result = await fetch(input);
const window = new Window({ url: input });
const doc = window.document;
@ -53,7 +61,7 @@ async function main() {
await window.happyDOM.waitUntilComplete();
const readable = new Readability(doc).parse();
if (!readable?.textContent) {
throw new Error(
throw new InputError(
"Unable to read webpage. This usually means it's blocking scrapers.",
);
}
@ -63,7 +71,7 @@ async function main() {
const home = os.homedir();
const cwd = path.join(home, ".ttsc");
if (editContent) {
if (edit) {
const file = path.join(cwd, "input.txt");
await fs.writeFile(file, input, "utf8");
const editor = process.env.EDITOR || "nano";
@ -93,7 +101,7 @@ async function main() {
stderr: "inherit",
})`uv run -- python -u main.py`;
if (!waitAndCombine) {
if (!wait) {
koko.catch(() => {});
koko.then(() => {
const stop = Date.now();
@ -127,7 +135,7 @@ async function main() {
const file = path.join(cwd, `${info.file}.wav`);
if (waitAndCombine) {
if (wait) {
files.push(file);
process.stdout.write(".");
} else {
@ -139,12 +147,12 @@ async function main() {
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
})`mpv${pausePlayer ? " --pause" : ""} ${file}`;
})`mpv ${file}`;
}
}
await koko;
if (!waitAndCombine) return;
if (!wait) return;
const stop = Date.now();
const duration = ((stop - start) / 1000).toFixed(2);
@ -154,7 +162,7 @@ async function main() {
try {
await execa({ cwd, shell: true })`sox ${files} output.wav`;
} catch (err) {
if (err instanceof ExecaError && err.code === "ENOENT") {
if (err instanceof Object && "code" in err && err.code === "ENOENT") {
console.error("Sox not found. Unable to combine outputs.");
console.log(chalk.yellow(`Audio available in ${cwd}`));
return;
@ -174,7 +182,10 @@ async function main() {
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
})`mpv${pausePlayer ? " --pause" : ""} output.wav`;
})`mpv --pause output.wav`;
}
main().catch(handleTopLevel);
main().catch((err) => {
console.error(err);
process.exit(1);
});