Compare commits
36 Commits
b91c4bf5e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43e0af918 | ||
|
|
46c9fe9fac | ||
|
|
eb3f58b2e6 | ||
|
|
6e8a336143 | ||
|
|
839cea7fe6 | ||
|
|
131a45e305 | ||
|
|
7fb261a3b7 | ||
|
|
7ad5d10378 | ||
|
|
3c6bf7184c | ||
|
|
9974a78394 | ||
|
|
421b47355b | ||
|
|
657b6af993 | ||
|
|
df85e5e603 | ||
|
|
b49c798fc7 | ||
|
|
83ac8709b7 | ||
|
|
58d8c352f3 | ||
|
|
14305859de | ||
|
|
0815d64802 | ||
|
|
b515275407 | ||
|
|
ac09e4ed08 | ||
|
|
62e9754ab0 | ||
|
|
ff3accdc76 | ||
|
|
60e288454c | ||
|
|
324aea5775 | ||
|
|
182ccd34ca | ||
|
|
7a6c2488da | ||
|
|
073d027880 | ||
|
|
70fe6fccdb | ||
|
|
ce6933377a | ||
|
|
edf0d3cffb | ||
|
|
91d28779d6 | ||
|
|
2c83d402fc | ||
|
|
e1887980d4 | ||
|
|
83c912d8e0 | ||
|
|
697cf74cc3 | ||
|
|
5090d2669b |
3
.devcontainer.json
Normal file
3
.devcontainer.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"image": "devpit:latest"
|
||||
}
|
||||
32
.eslintrc.json
Normal file
32
.eslintrc.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"es2022": true,
|
||||
"node": true,
|
||||
"browser": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"plugins": [],
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"node_modules/",
|
||||
"out1",
|
||||
"dist/",
|
||||
"build/"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
tmp
|
||||
tmp
|
||||
root
|
||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "npm start",
|
||||
"name": "Run npm start",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
"eslint.workingDirectories": [
|
||||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
60
cli-ink.js
Executable file
60
cli-ink.js
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import 'dotenv/config';
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import InkApp from './src/ui/InkApp.jsx';
|
||||
import terminalService from './src/terminalService.js';
|
||||
|
||||
// Start the PTY backend independent from UI lifecycle
|
||||
terminalService.start();
|
||||
|
||||
const { unmount } = render(React.createElement(InkApp));
|
||||
|
||||
// ESC to exit (only bare ESC, not escape sequences like arrows)
|
||||
if (process.stdin.isTTY) {
|
||||
try { process.stdin.setRawMode(true); } catch { }
|
||||
let escPending = false;
|
||||
let escTimer = null;
|
||||
|
||||
const exitCleanly = () => {
|
||||
unmount();
|
||||
try { terminalService.dispose(); } catch { }
|
||||
try { process.stdin.setRawMode(false); } catch { }
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const onData = (data) => {
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
||||
for (const byte of buffer) {
|
||||
// Ctrl-C (ETX)
|
||||
if (byte === 0x03) {
|
||||
return exitCleanly();
|
||||
}
|
||||
if (!escPending) {
|
||||
if (byte === 0x1b) { // ESC
|
||||
escPending = true;
|
||||
escTimer = setTimeout(() => {
|
||||
// No additional byte followed: treat as bare ESC
|
||||
escPending = false;
|
||||
escTimer = null;
|
||||
exitCleanly();
|
||||
}, 120);
|
||||
}
|
||||
// else: ignore other bytes
|
||||
} else {
|
||||
// Some byte followed ESC quickly: it's an escape sequence → cancel exit
|
||||
if (escTimer) { clearTimeout(escTimer); escTimer = null; }
|
||||
escPending = false;
|
||||
// Do not process further for exit
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on('data', onData);
|
||||
|
||||
// Also handle SIGINT in case raw mode changes or comes from elsewhere
|
||||
const onSigint = () => exitCleanly();
|
||||
process.on('SIGINT', onSigint);
|
||||
}
|
||||
|
||||
|
||||
223
cli.js
223
cli.js
@@ -1,21 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
import 'dotenv/config';
|
||||
import OpenAI from 'openai';
|
||||
import terminalKit from 'terminal-kit';
|
||||
|
||||
//npm install tiktoken
|
||||
//csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import { promises as fs, unwatchFile } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { resourceUsage } from 'node:process';
|
||||
|
||||
if (!Array.fromAsync) {
|
||||
Array.fromAsync = async function fromAsync(asyncIterable) {
|
||||
const array = [];
|
||||
for await (const item of asyncIterable) {
|
||||
array.push(item);
|
||||
}
|
||||
return array;
|
||||
};
|
||||
}
|
||||
|
||||
function renderUsage(usage) {
|
||||
const inputTokens = usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
||||
const cacheTokens = usage.input_tokens_details.cached_tokens;
|
||||
const outputToken = usage.output_tokens;
|
||||
console.log(' Cost', inputTokens, cacheTokens, outputToken);
|
||||
console.log('\nCost', inputTokens, cacheTokens, outputToken);
|
||||
}
|
||||
|
||||
function printIndented(indentNum, ...args) {
|
||||
@@ -34,6 +43,25 @@ function printIndented(indentNum, ...args) {
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const term = terminalKit.terminal;
|
||||
|
||||
// Global key handler so CTRL-C works everywhere (input fields, loops, etc.).
|
||||
// Disable mouse tracking so terminal mouse wheel keeps controlling scrollback.
|
||||
term.grabInput({ mouse: false });
|
||||
term.on('key', (name) => {
|
||||
if (name === 'CTRL_C') {
|
||||
term.grabInput(false);
|
||||
term.processExit(0);
|
||||
}
|
||||
});
|
||||
|
||||
async function askUserForInput() {
|
||||
term.cyan("Enter your request: ");
|
||||
const input = await term.inputField({ mouse: false }).promise;
|
||||
console.log('\n');
|
||||
return input;
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
const toolsDir = path.join(__dirname, "tools");
|
||||
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
|
||||
@@ -48,101 +76,116 @@ async function loadTools() {
|
||||
);
|
||||
return Object.fromEntries(toolEntries);
|
||||
}
|
||||
|
||||
streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), 'Erstelle eine React Project für eine Abovverwaltung. Mui, Sqllite, Express, Nodejs, KEIN Typescript, Aber ESM import. webpack, kein vite. HRM, nodemon');
|
||||
|
||||
|
||||
let counter = 0;
|
||||
async function streamOnce(openai, userText) {
|
||||
const toolsByFile = await loadTools();
|
||||
let previousResponseId;
|
||||
|
||||
let input = [
|
||||
{"role": "developer", "content": [ {"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.` }] },
|
||||
{"role": "user", "content": [ {"type": "input_text","text": userText } ]},
|
||||
]
|
||||
|
||||
while(input.length > 0){
|
||||
|
||||
const call = {
|
||||
model: 'gpt-5-mini',
|
||||
input: input,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
reasoning: { effort: 'medium', summary: 'detailed' },
|
||||
tools: Object.values(toolsByFile).map(t => t.def),
|
||||
store: true,
|
||||
}
|
||||
if(previousResponseId) call.previous_response_id = previousResponseId;
|
||||
|
||||
console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
|
||||
,"\n",counter++,"\n",'----INPUT-----------------'
|
||||
,"\n",call.input.map(i => JSON.stringify(i)),"\n",
|
||||
'--------CALL-------------',"\n");
|
||||
const stream = await openai.responses.stream(call);
|
||||
stream.on('response.created', (event) => {
|
||||
previousResponseId = event.response.id;
|
||||
});
|
||||
stream.on('response.reasoning_summary_text.delta', (event) => {
|
||||
process.stdout.write('o')
|
||||
});
|
||||
stream.on('response.reasoning_summary_text.done', () => {
|
||||
process.stdout.write('\n');
|
||||
//clear on next delta
|
||||
});
|
||||
|
||||
stream.on('response.output_text.delta', (event) => {
|
||||
process.stdout.write('.')
|
||||
});
|
||||
let previousResponseId;
|
||||
while(true){
|
||||
|
||||
|
||||
stream.on('response.output_item.added', (event) => {
|
||||
if(event.item && event.item.type === 'function_call'){
|
||||
//console.log('function call:', event.item);
|
||||
// Block for user input before kicking off the LLM loop
|
||||
const userText = await askUserForInput();
|
||||
await streamOnce(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), userText );
|
||||
//await streamOnce(new OpenAI({ baseURL: "https://api.cerebras.ai/v1",apiKey: "csk-8jftdte6r6vf8fdvp9xkyek5t3jnc6jfhh93d3ewfcwxxvh9" }), userText );
|
||||
async function streamOnce(openai, userText) {
|
||||
const toolsByFile = await loadTools();
|
||||
|
||||
|
||||
const systemprompt = {"role": "developer", "content": [ {
|
||||
"type": "input_text","text": `You are an interactive CLI AI assistant. Follow the user's instructions.
|
||||
If a tool is available and relevant, plan to use it.
|
||||
Tools:
|
||||
|
||||
list_files - (no/empty path means root)
|
||||
patch_files - (zum anlegen, ändern und löschen von Dateien)
|
||||
read_file - (nach zeilen)
|
||||
ripgrep - suchmusater und dateimuster
|
||||
websearch - eine Google Suche machen mit Schlüsselwörtern
|
||||
`
|
||||
}]};
|
||||
|
||||
const input = [{"role": "user", "content": [ {"type": "input_text","text": userText } ]}];
|
||||
|
||||
do{
|
||||
|
||||
const call = {
|
||||
model: 'gpt-4.1-nano',
|
||||
input: counter == 0 ? [systemprompt,...structuredClone(input)] : structuredClone(input),
|
||||
text: { format: { type: 'text' }/*, verbosity: 'low' */},
|
||||
//reasoning: { effort: 'minimal', summary: 'detailed' },
|
||||
tools: Object.values(toolsByFile).map(t => t.def),
|
||||
store: true,
|
||||
}
|
||||
});
|
||||
stream.on('response.function_call_arguments.delta', (event) => {
|
||||
process.stdout.write('x');
|
||||
});
|
||||
if(previousResponseId) call.previous_response_id = previousResponseId;
|
||||
|
||||
const functionCalls = [];
|
||||
//console.log("\n\n\n\n\n------NEW OPENAI CALL-"+input.length+"-------------"
|
||||
// ,"\n",counter++,"\n",'----INPUT-----------------'
|
||||
// ,"\n",call.input.map(i => JSON.stringify(i)),"\n",
|
||||
// '--------CALL-------------',call,"\n");
|
||||
const stream = await openai.responses.stream(call);
|
||||
stream.on('response.created', (event) => {
|
||||
previousResponseId = event.response.id;
|
||||
});
|
||||
stream.on('response.reasoning_summary_text.delta', (event) => {
|
||||
process.stdout.write('o')
|
||||
});
|
||||
stream.on('response.reasoning_summary_text.done', () => {
|
||||
process.stdout.write('\n');
|
||||
//clear on next delta
|
||||
});
|
||||
|
||||
stream.on('response.output_item.done', async (event) => {
|
||||
if(event.item && event.item.type === 'function_call'){
|
||||
const id = event.item.call_id;
|
||||
const name = event.item.name;
|
||||
let args = {};
|
||||
try {
|
||||
args = JSON.parse(event.item.arguments);
|
||||
} catch (e){
|
||||
console.error('Error parsing arguments:', e, event.item.arguments);
|
||||
stream.on('response.output_text.delta', (event) => {
|
||||
process.stdout.write('.')
|
||||
});
|
||||
|
||||
|
||||
stream.on('response.output_item.added', (event) => {
|
||||
if(event.item && event.item.type === 'function_call'){
|
||||
//console.log('function call:', event.item);
|
||||
}
|
||||
console.log(' function call:', id, name);
|
||||
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
|
||||
});
|
||||
stream.on('response.function_call_arguments.delta', (event) => {
|
||||
process.stdout.write('x');
|
||||
});
|
||||
|
||||
const functionCalls = [];
|
||||
|
||||
stream.on('response.output_item.done', async (event) => {
|
||||
if(event.item && event.item.type === 'function_call'){
|
||||
const id = event.item.call_id;
|
||||
const name = event.item.name;
|
||||
let args = {};
|
||||
try {
|
||||
args = JSON.parse(event.item.arguments);
|
||||
} catch (e){
|
||||
// console.error('Error parsing arguments:', e, event.item.arguments);
|
||||
}
|
||||
//console.log(' function call:', id, name);
|
||||
functionCalls.push({ id, name, args, promise: toolsByFile[name].run(args) });
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('response.completed', async (event) => {
|
||||
printIndented(10,renderUsage(event.response.usage));
|
||||
if (event.response.output.filter(i => i.type === 'message').length > 0) printIndented(10,event.response.output.filter(i => i.type === 'message').map(i => i.content[0].text).join('\n'));
|
||||
});
|
||||
|
||||
await Array.fromAsync(stream);
|
||||
input.length = 0;
|
||||
|
||||
for (const call of functionCalls) {
|
||||
//try {
|
||||
const result = await call.promise;
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(result),
|
||||
})
|
||||
printIndented(10,'function call result:',result);
|
||||
//} catch (err) {
|
||||
// console.error('Error in function call:', call.name, err);
|
||||
//}
|
||||
}
|
||||
});
|
||||
}while(input.length > 0)
|
||||
|
||||
stream.on('response.completed', async (event) => {
|
||||
printIndented(10,renderUsage(event.response.usage));
|
||||
if (event.response.output.filter(i => i.type === 'message').length > 0) printIndented(10, "Textresult:",event.response.output.filter(i => i.type === 'message').map(i => i.content[0].text));
|
||||
});
|
||||
|
||||
await Array.fromAsync(stream);
|
||||
input=[];
|
||||
|
||||
for (const call of functionCalls) {
|
||||
//try {
|
||||
const result = await call.promise;
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(result),
|
||||
})
|
||||
printIndented(10,'function call result:',result);
|
||||
//} catch (err) {
|
||||
// console.error('Error in function call:', call.name, err);
|
||||
//}
|
||||
}
|
||||
//console.log('OPENAI STREAM FINISHED');
|
||||
}
|
||||
|
||||
//console.log('OPENAI STREAM FINISHED');
|
||||
}
|
||||
|
||||
109
cli2.js
Normal file
109
cli2.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import ModelDialog from './modelDialog.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const modelDialog = new ModelDialog({model: 'gpt-5-mini'});
|
||||
|
||||
modelDialog.on('outputUpdate', (output) => {
|
||||
//console.log(chalk.blue('output event'),output);
|
||||
});
|
||||
modelDialog.on('reasoningUpdate', (output) => {
|
||||
//console.log(chalk.blue('reasoning event'),output);
|
||||
});
|
||||
|
||||
// $ / 1million tokens
|
||||
const price = {
|
||||
'gpt-5-2025-08-07': {
|
||||
input: 1.25,
|
||||
cached: 0.125,
|
||||
output: 10
|
||||
},
|
||||
'gpt-5-mini-2025-08-07': {
|
||||
input: 0.25,
|
||||
cached: 0.025,
|
||||
output: 2
|
||||
},
|
||||
'gpt-5-nano-2025-08-07': {
|
||||
input: 0.05,
|
||||
cached: 0.005,
|
||||
output: 0.4
|
||||
},
|
||||
'gpt-4.1-2025-04-14': {
|
||||
input: 2,
|
||||
cached: 0.5,
|
||||
output: 8
|
||||
},
|
||||
'gpt-4.1-mini-2025-04-14': {
|
||||
input: 0.4,
|
||||
cached: 0.1,
|
||||
output: 1.6
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
(async ()=>{
|
||||
//const output = await modelDialog.interrogate('Can you remember "seven" ?');
|
||||
//console.log(output.output,JSON.stringify(output.reasoning,null,2));
|
||||
//const output2 = await modelDialog.interrogate('read a file that is what you remebered plus 1 as a word with txt ending, check that file.');
|
||||
|
||||
|
||||
|
||||
const output2 = await modelDialog.interrogate('schau dich mal um und wenn du html dateien findest, dann invertiere den gradient.');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
console.log('final output:',output2.output);
|
||||
console.log('reasoning:',output2.reasoning);
|
||||
//Ti: { 'gpt-5-2025-08-07': 3019 } Tc: { 'gpt-5-2025-08-07': 0 } To: { 'gpt-5-2025-08-07': 751 }
|
||||
console.log('Ti:',output2.inputTokens,'Tc:',output2.cachedTokens,'To:',output2.outputTokens);
|
||||
// cost breakdown per model and totals (prices are per 1M tokens)
|
||||
const perMillion = 1_000_000;
|
||||
const models = new Set([
|
||||
...Object.keys(output2.inputTokens || {}),
|
||||
...Object.keys(output2.cachedTokens || {}),
|
||||
...Object.keys(output2.outputTokens || {})
|
||||
]);
|
||||
|
||||
let grandTotal = 0;
|
||||
for (const model of models) {
|
||||
const inputT = (output2.inputTokens || {})[model];
|
||||
const cachedT = (output2.cachedTokens || {})[model];
|
||||
const outputT = (output2.outputTokens || {})[model];
|
||||
|
||||
const p = price[model];
|
||||
const inputCost = (typeof inputT === 'number' && p) ? (inputT / perMillion) * p.input : undefined;
|
||||
const cachedCost = (typeof cachedT === 'number' && p) ? (cachedT / perMillion) * p.cached : undefined;
|
||||
const outputCost = (typeof outputT === 'number' && p) ? (outputT / perMillion) * p.output : undefined;
|
||||
|
||||
const subtotal = [inputCost, cachedCost, outputCost].every(v => typeof v === 'number')
|
||||
? (inputCost + cachedCost + outputCost)
|
||||
: undefined;
|
||||
|
||||
if (typeof subtotal === 'number') grandTotal += subtotal;
|
||||
|
||||
console.log('cost for', model, {
|
||||
inputCost: parseFloat(inputCost.toFixed(6)),
|
||||
cachedCost: parseFloat(cachedCost.toFixed(6)),
|
||||
outputCost: parseFloat(outputCost.toFixed(6)),
|
||||
subtotal: parseFloat(subtotal.toFixed(4))
|
||||
});
|
||||
}
|
||||
|
||||
//console.log('total cost:', grandTotal);
|
||||
})()
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules',
|
||||
'out1',
|
||||
'dist',
|
||||
'build'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
browser: true,
|
||||
node: true
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// baseline rules; extend as needed
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-undef': 'error',
|
||||
'no-console': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
132
index.html
Normal file
132
index.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pelikan fährt Rad – SVG</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; background:#0e1320; color:#e7ecf3; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
|
||||
.wrap { display:grid; place-items:center; height:100%; }
|
||||
.cap { position: fixed; bottom: 0.75rem; left: 0; right: 0; text-align:center; font-size: 0.9rem; opacity: .8; }
|
||||
svg { width: min(92vw, 960px); height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<!-- Rein vektoriell gezeichneter Pelikan auf einem Fahrrad. -->
|
||||
<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d">
|
||||
<title id="t">Pelikan fährt Fahrrad</title>
|
||||
<desc id="d">Ein stilisierter Pelikan mit großem Schnabel fährt ein Fahrrad mit zwei Rädern; einfache Formen, klare Farben.</desc>
|
||||
|
||||
<!-- Hintergrund-Deko -->
|
||||
<defs>
|
||||
<radialGradient id="sky" cx="50%" cy="40%" r="70%">
|
||||
<stop offset="0%" stop-color="#1b2a4a" />
|
||||
<stop offset="100%" stop-color="#0e1320" />
|
||||
</radialGradient>
|
||||
<linearGradient id="beakGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stop-color="#ffb347"/>
|
||||
<stop offset="100%" stop-color="#ff7f11"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stop-color="#4bd5ff"/>
|
||||
<stop offset="100%" stop-color="#6a5cff"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#sky)"/>
|
||||
|
||||
<!-- Bodenlinie -->
|
||||
<g opacity="0.35">
|
||||
<ellipse cx="400" cy="520" rx="300" ry="40" fill="#000" />
|
||||
</g>
|
||||
|
||||
<!-- Fahrrad -->
|
||||
<g id="bike" transform="translate(0,0)" filter="url(#shadow)">
|
||||
<!-- Räder -->
|
||||
<g id="wheels" stroke="#cfe7ff" stroke-width="6" fill="none">
|
||||
<circle cx="260" cy="460" r="90" />
|
||||
<circle cx="540" cy="460" r="90" />
|
||||
<!-- Speichen -->
|
||||
<g stroke="#9fd0ff" stroke-width="3" opacity="0.6">
|
||||
<line x1="260" y1="370" x2="260" y2="550" />
|
||||
<line x1="170" y1="460" x2="350" y2="460" />
|
||||
<line x1="200" y1="400" x2="320" y2="520" />
|
||||
<line x1="200" y1="520" x2="320" y2="400" />
|
||||
<line x1="540" y1="370" x2="540" y2="550" />
|
||||
<line x1="450" y1="460" x2="630" y2="460" />
|
||||
<line x1="480" y1="400" x2="600" y2="520" />
|
||||
<line x1="480" y1="520" x2="600" y2="400" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Rahmen -->
|
||||
<g id="frame" stroke="url(#frameGrad)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<polyline points="260,460 360,420 450,460 540,460" />
|
||||
<line x1="360" y1="420" x2="410" y2="360" />
|
||||
<line x1="410" y1="360" x2="520" y2="360" />
|
||||
<line x1="520" y1="360" x2="540" y2="460" />
|
||||
</g>
|
||||
|
||||
<!-- Kurbel und Pedale -->
|
||||
<g id="crank" transform="translate(405,440)">
|
||||
<circle r="16" fill="#b7d8ff" />
|
||||
<g stroke="#b7d8ff" stroke-width="6" stroke-linecap="round">
|
||||
<line x1="0" y1="0" x2="28" y2="-28" />
|
||||
</g>
|
||||
<circle cx="28" cy="-28" r="8" fill="#e5f3ff" />
|
||||
</g>
|
||||
|
||||
<!-- Lenker und Sattel -->
|
||||
<g stroke="#cfe7ff" stroke-width="10" stroke-linecap="round" fill="none">
|
||||
<path d="M520 360 C 555 335, 585 345, 600 360"/>
|
||||
</g>
|
||||
<rect x="325" y="330" width="70" height="16" rx="8" fill="#cfe7ff" />
|
||||
</g>
|
||||
|
||||
<!-- Pelikan -->
|
||||
<g id="pelican" transform="translate(0,0)">
|
||||
<!-- Körper -->
|
||||
<ellipse cx="360" cy="360" rx="90" ry="70" fill="#f2f6fb" stroke="#e1e8f5" stroke-width="4" />
|
||||
<!-- Flügel -->
|
||||
<path d="M330 345 C 275 350, 250 395, 270 430 C 295 470, 355 470, 390 440 C 410 420, 400 380, 370 360 Z" fill="#eaf2fb" stroke="#d7e4f6" stroke-width="3" />
|
||||
<!-- Hals -->
|
||||
<path d="M400 335 C 445 320, 470 300, 500 295 C 520 292, 540 305, 540 325 C 540 345, 520 360, 500 360 C 470 360, 445 350, 415 355" fill="none" stroke="#f2f6fb" stroke-width="20" stroke-linecap="round" />
|
||||
<!-- Kopf -->
|
||||
<circle cx="520" cy="325" r="28" fill="#f7fbff" stroke="#e1e8f5" stroke-width="3" />
|
||||
<!-- Auge -->
|
||||
<circle cx="528" cy="322" r="5" fill="#21314f" />
|
||||
<circle cx="526" cy="320" r="2" fill="#ffffff" />
|
||||
<!-- Schnabel -->
|
||||
<path d="M535 330 C 585 330, 630 338, 675 352 C 640 360, 600 368, 555 368 C 545 360, 538 348, 535 330 Z" fill="url(#beakGrad)" stroke="#ff9a2a" stroke-width="3" />
|
||||
<!-- Kehlsack angedeutet -->
|
||||
<path d="M540 338 C 585 345, 590 352, 552 365" fill="none" stroke="#ffb347" stroke-width="4" opacity="0.7" />
|
||||
<!-- Beine -->
|
||||
<g stroke="#ffb347" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M340 420 L 320 455" />
|
||||
<path d="M380 420 L 390 450" />
|
||||
</g>
|
||||
<!-- Füße auf Pedalen -->
|
||||
<g fill="#ffb347">
|
||||
<circle cx="320" cy="455" r="8" />
|
||||
<circle cx="390" cy="450" r="8" />
|
||||
</g>
|
||||
<!-- Schwanz -->
|
||||
<path d="M280 365 C 255 360, 240 350, 230 340 C 248 368, 260 390, 290 392" fill="#eaf2fb" stroke="#d7e4f6" stroke-width="3" />
|
||||
</g>
|
||||
|
||||
<!-- Sterne / Deko -->
|
||||
<g fill="#9fd0ff" opacity="0.8">
|
||||
<circle cx="90" cy="80" r="2"/>
|
||||
<circle cx="160" cy="120" r="1.5"/>
|
||||
<circle cx="720" cy="90" r="2.2"/>
|
||||
<circle cx="650" cy="160" r="1.8"/>
|
||||
<circle cx="120" cy="200" r="1.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cap">SVG Illustration: Pelikan auf Fahrrad. Datei lokal öffnen oder auf einen Webserver legen.</div>
|
||||
</body>
|
||||
</html>
|
||||
198
modelDialog.js
Normal file
198
modelDialog.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import OpenAI from 'openai';
|
||||
import 'dotenv/config';
|
||||
import EventEmitter from 'events';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import chalk from 'chalk';
|
||||
|
||||
async function loadTools() {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const toolsDir = path.join(__dirname, "tools");
|
||||
const dirents = await fs.readdir(toolsDir, { withFileTypes: true });
|
||||
const toolEntries = await Promise.all(
|
||||
dirents
|
||||
.filter((dirent) => dirent.isFile() && dirent.name.endsWith(".js"))
|
||||
.map(async (dirent) => {
|
||||
const fileName = dirent.name.replace(/\.js$/, "");
|
||||
const module = await import(`file://${path.join(toolsDir, dirent.name)}`);
|
||||
return [fileName, { def: module.default, run: module.run }];
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(toolEntries);
|
||||
}
|
||||
const toolsByFile = await loadTools();
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
const systemprompt = {"role": "developer", "content": [ { "type": "input_text","text":
|
||||
|
||||
`You are a helpful assistant.`
|
||||
|
||||
}]};
|
||||
|
||||
if (!Array.fromAsync) {
|
||||
Array.fromAsync = async function fromAsync(asyncIterable) {
|
||||
const array = [];
|
||||
for await (const item of asyncIterable) {
|
||||
array.push(item);
|
||||
}
|
||||
return array;
|
||||
};
|
||||
}
|
||||
|
||||
class ModelDialog {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.messages = [systemprompt];
|
||||
this.messagesSent = [];
|
||||
this.isActive = false;
|
||||
this.currentStream = null;
|
||||
this.previousResponseId = null;
|
||||
this.emitter = new EventEmitter();
|
||||
this.inputTokens = {};
|
||||
this.outputTokens = {};
|
||||
this.cachedTokens = {};
|
||||
this.lastDebouncedUpdate = 0;
|
||||
};
|
||||
|
||||
handleUsage = (usage, model) => {
|
||||
if (typeof this.inputTokens[model] !== 'number') this.inputTokens[model] = 0;
|
||||
if (typeof this.outputTokens[model] !== 'number') this.outputTokens[model] = 0;
|
||||
if (typeof this.cachedTokens[model] !== 'number') this.cachedTokens[model] = 0;
|
||||
this.inputTokens[model] += usage.input_tokens - usage.input_tokens_details.cached_tokens;
|
||||
this.outputTokens[model] += usage.output_tokens;
|
||||
this.cachedTokens[model] += usage.input_tokens_details.cached_tokens;
|
||||
}
|
||||
|
||||
on = (event, callback) => {
|
||||
const debounceTime = 1000; // 1 second
|
||||
|
||||
const debouncedCallback = (...args) => {
|
||||
const now = Date.now();
|
||||
if (now - this.lastDebouncedUpdate >= debounceTime) {
|
||||
this.lastDebouncedUpdate = now;
|
||||
callback(...args);
|
||||
}
|
||||
};
|
||||
|
||||
this.emitter.on(event, debouncedCallback);
|
||||
}
|
||||
|
||||
interrogate = async (prompt) => {
|
||||
if(this.isActive) return;
|
||||
this.isActive = true;
|
||||
|
||||
this.messages.push({"role": "user", "content": [ {"type": "input_text","text": prompt }]});
|
||||
|
||||
const outputs = [];
|
||||
|
||||
do{
|
||||
const messagesToSend = this.messages.splice(0);
|
||||
console.log(chalk.blue('sending messages:'),messagesToSend.length);
|
||||
//console.log(chalk.blue('messages:'),JSON.stringify(messagesToSend,null,2));
|
||||
this.messagesSent.push(...messagesToSend);
|
||||
|
||||
const model = this.options.model || 'gpt-5-mini';
|
||||
if(!['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini'].includes(model)){
|
||||
throw new Error('Invalid model: ' + model);
|
||||
}
|
||||
|
||||
const call = {
|
||||
model: model,
|
||||
input: messagesToSend,
|
||||
text: { format: { type: 'text' } },
|
||||
tools: Object.values(toolsByFile).map(t => t.def),
|
||||
store: true,
|
||||
previous_response_id: this.previousResponseId,
|
||||
parallel_tool_calls: true,
|
||||
include: ['reasoning.encrypted_content']
|
||||
}
|
||||
if(model.startsWith('gpt-5')){
|
||||
call.reasoning = { effort: 'low', summary: 'detailed' };
|
||||
//call.text.format.verbosity = 'low';
|
||||
}
|
||||
|
||||
|
||||
this.currentStream = openai.responses.stream(call);
|
||||
|
||||
this.currentStream.on('response.created', (event) => {
|
||||
this.previousResponseId = event.response.id;
|
||||
});
|
||||
|
||||
const deltas = [];
|
||||
this.currentStream.on('response.output_text.delta', (event) => {
|
||||
deltas.push(event.delta);
|
||||
this.emitter.emit('outputUpdate', deltas.join(''));
|
||||
});
|
||||
|
||||
const reasoningDeltas = [];
|
||||
this.currentStream.on('response.reasoning_summary_text.delta', (event) => {
|
||||
if(!reasoningDeltas[event.summary_index]) reasoningDeltas[event.summary_index] = [];
|
||||
reasoningDeltas[event.summary_index].push(event.delta);
|
||||
this.emitter.emit('reasoningUpdate', reasoningDeltas[event.summary_index].join(''));
|
||||
});
|
||||
|
||||
this.currentStream.on('response.reasoning_summary_text.done', (event) => {
|
||||
//console.log(event);
|
||||
});
|
||||
this.currentStream.on('response.function_call_arguments.delta', (event) => {
|
||||
process.stdout.write(chalk.yellow(event.delta));
|
||||
});
|
||||
this.currentStream.on('response.function_call_arguments.done', (event) => {
|
||||
process.stdout.write("\n");
|
||||
});
|
||||
|
||||
|
||||
this.currentStream.on('response.completed', async (event) => {
|
||||
//console.log(chalk.blue('response completed:'),event.response.usage);
|
||||
this.handleUsage(event.response.usage, event.response.model);
|
||||
outputs.push(...event.response.output);
|
||||
|
||||
for(const toolCall of event.response.output.filter(i => i.type === 'function_call')){
|
||||
// Limit the 'arguments' field to 400 characters for logging
|
||||
const limitedArgs = typeof toolCall.arguments === 'string'
|
||||
? (toolCall.arguments.length > 400 ? toolCall.arguments.slice(0, 400) + '...[truncated]' : toolCall.arguments)
|
||||
: toolCall.arguments;
|
||||
|
||||
const tool = toolsByFile[toolCall.name];
|
||||
let args;
|
||||
try{
|
||||
args = JSON.parse(toolCall.arguments);
|
||||
} catch(e){
|
||||
console.error(chalk.red('Error parsing arguments:'), e, toolCall.arguments);
|
||||
this.messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: toolCall.call_id,
|
||||
output: {error: 'Exception in parsing arguments', exception: e},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const result = await tool.run(args);
|
||||
console.log(chalk.green('function call result:'),'<toolCall.name>',toolCall.name,'</toolCall.name>\n','<args>',limitedArgs,'</args>\n','<result>',JSON.stringify(result).slice(0,100),'...</result>');
|
||||
this.messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: toolCall.call_id,
|
||||
output: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Array.fromAsync(this.currentStream);
|
||||
|
||||
console.log(chalk.green('Tico'),[Object.values(this.inputTokens),Object.values(this.cachedTokens),Object.values(this.outputTokens)]);
|
||||
console.log(chalk.green('Do we need to loop? messages in array = '),this.messages.length)
|
||||
|
||||
} while(this.messages.length > 0);
|
||||
|
||||
this.isActive = false;
|
||||
this.lastDebouncedUpdate = 0;
|
||||
return {
|
||||
output: outputs.filter(i => i.type === 'message').map(i => i.content[0].text) ,
|
||||
reasoning: outputs.filter(i => i.type === 'reasoning').map(i => i.summary.map(j => j.text).join('\n')),
|
||||
inputTokens: this.inputTokens, outputTokens: this.outputTokens, cachedTokens: this.cachedTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ModelDialog;
|
||||
1337
package-lock.json
generated
1337
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
60
package.json
60
package.json
@@ -3,52 +3,9 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "cli.js",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.6.0",
|
||||
"asynckit": "^0.4.0",
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"chalk": "^5.5.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"delayed-stream": "^1.0.0",
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"esbuild": "^0.25.8",
|
||||
"event-target-shim": "^5.0.1",
|
||||
"form-data": "^4.0.4",
|
||||
"form-data-encoder": "^1.7.2",
|
||||
"formdata-node": "^4.4.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"get-tsconfig": "^4.10.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2",
|
||||
"humanize-ms": "^1.2.1",
|
||||
"math-intrinsics": "^1.1.0",
|
||||
"mime-db": "^1.52.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"ms": "^2.1.3",
|
||||
"node-domexception": "^1.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"openai": "^4.104.0",
|
||||
"resolve-pkg-maps": "^1.0.0",
|
||||
"tr46": "^0.0.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
"undici-types": "^5.26.5",
|
||||
"web-streams-polyfill": "^4.0.0-beta.3",
|
||||
"webidl-conversions": "^3.0.1",
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node cli.js",
|
||||
"start:ink": "tsx cli-ink.js",
|
||||
"test": "node tests/run-all.js",
|
||||
"test:patch": "node tests/run-tests.js",
|
||||
"test:readfile": "node tests/run-readfile-tests.js",
|
||||
@@ -58,5 +15,18 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"chalk": "^5.5.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"exa-js": "^1.8.27",
|
||||
"ink": "^6.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.1.1",
|
||||
"terminal-kit": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.4"
|
||||
}
|
||||
}
|
||||
|
||||
359
plan.md
Normal file
359
plan.md
Normal file
@@ -0,0 +1,359 @@
|
||||
You're on the right track! You want to create a **Roundcube plugin** that allows users to **enhance or rewrite the email body using an LLM**, by sending the current message content to a backend API (your LLM service), then replacing the textarea content with the response.
|
||||
|
||||
Here’s a complete working example of such a plugin, called `llm_compose_helper`. It adds a button to the compose screen, sends the current message text to a configured URL via AJAX, and replaces the message body with the LLM-generated result.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Goal
|
||||
- Add a "Rewrite with AI" button in the compose window.
|
||||
- On click: open a popup asking the user for a rewrite prompt/instructions.
|
||||
- Submit both the current message body and the user prompt to the configured LLM API endpoint.
|
||||
- Replace the `<textarea>` content with the returned text.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Plugin Structure
|
||||
```
|
||||
plugins/
|
||||
llm_compose_helper/
|
||||
llm_compose_helper.php <-- Main plugin class
|
||||
config.inc.php <-- Configuration file
|
||||
localization/en_US.inc <-- Language labels
|
||||
js/llm_compose_helper.js <-- Client-side JavaScript
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `llm_compose_helper.php` – Plugin Class
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* LLM Compose Helper Plugin
|
||||
*
|
||||
* Allows rewriting the message body using an external LLM service
|
||||
*
|
||||
* @version 1.0
|
||||
* @author You
|
||||
* @license GPL
|
||||
*/
|
||||
class llm_compose_helper extends rcube_plugin
|
||||
{
|
||||
public $task = 'mail';
|
||||
|
||||
function init()
|
||||
{
|
||||
// Load configuration
|
||||
$this->load_config();
|
||||
|
||||
// Load localization texts (available in JS if second param = true)
|
||||
$this->add_texts('localization/', true);
|
||||
|
||||
// Only run in compose view
|
||||
$rcmail = rcmail::get_instance();
|
||||
if ($rcmail->action == 'compose') {
|
||||
// Include JS and CSS
|
||||
$this->include_script('js/llm_compose_helper.js');
|
||||
|
||||
// Register UI placeholder handler
|
||||
$this->register_handler('plugin.llm_button', array($this, 'llm_button'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the button HTML to be inserted into the toolbar
|
||||
*/
|
||||
function llm_button()
|
||||
{
|
||||
$rcmail = rcmail::get_instance();
|
||||
return html::tag('a', array(
|
||||
'href' => '#',
|
||||
'id' => 'llm_rewrite_button',
|
||||
'class' => 'button',
|
||||
'title' => $this->gettext('rewrite_with_llm'),
|
||||
'onclick' => 'return false;'
|
||||
), $this->gettext('rewrite_label'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `config.inc.php` – Plugin Configuration
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// URL of your LLM API endpoint
|
||||
$config['llm_api_url'] = 'https://your-llm-api.example.com/rewrite';
|
||||
|
||||
// Optional: headers (e.g., auth tokens)
|
||||
$config['llm_api_headers'] = array(
|
||||
'Authorization: Bearer your-secret-token',
|
||||
'Content-Type: application/json'
|
||||
);
|
||||
```
|
||||
|
||||
> 💡 You can later override this in `config/config.inc.php` globally.
|
||||
|
||||
---
|
||||
|
||||
## 3. `localization/en_US.inc`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
$labels = array(
|
||||
'rewrite_with_llm' => 'Rewrite message with AI',
|
||||
'rewrite_label' => 'AI Rewrite',
|
||||
'enter_prompt' => 'Enter rewrite prompt',
|
||||
'rewrite_submit' => 'Rewrite',
|
||||
'rewrite_cancel' => 'Cancel',
|
||||
);
|
||||
|
||||
$messages = array(
|
||||
'prompt_placeholder' => 'e.g., Make it shorter and more formal',
|
||||
);
|
||||
```
|
||||
|
||||
> Add other language files like `fr_FR.inc`, `de_DE.inc`, etc., if needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. `js/llm_compose_helper.js` – Client-Side Logic
|
||||
|
||||
```javascript
|
||||
// Wait for Roundcube to initialize
|
||||
rcmail.addEventListener('init', function() {
|
||||
const button = document.getElementById('llm_rewrite_button');
|
||||
if (!button) return;
|
||||
|
||||
// Insert button into compose toolbar
|
||||
const toolbar = rcmail.gui_objects.toolbar;
|
||||
if (toolbar) {
|
||||
toolbar.appendChild(button);
|
||||
}
|
||||
|
||||
// Attach click event
|
||||
$(button).click(function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const textarea = rcmail.env.html_editor ? rcmail.editor : document.getElementById('composebody');
|
||||
let messageText;
|
||||
|
||||
// Get current message body
|
||||
if (rcmail.env.html_editor && rcmail.editor && rcmail.editor.getData) {
|
||||
messageText = rcmail.editor.getData(); // CKEditor
|
||||
} else {
|
||||
messageText = $('#composebody').val();
|
||||
}
|
||||
|
||||
if (!messageText || messageText.trim() === '') {
|
||||
alert(rcmail.gettext('non_empty', 'llm_compose_helper'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build prompt dialog content
|
||||
var promptId = 'llm_prompt_input_' + Date.now();
|
||||
var dialogHtml = '<div style="padding:8px 0">' +
|
||||
'<label for="' + promptId + '">' + rcmail.gettext('enter_prompt', 'llm_compose_helper') + '</label>' +
|
||||
'<textarea id="' + promptId + '" style="width:100%;height:120px;box-sizing:border-box;margin-top:6px" placeholder="' + (rcmail.gettext('prompt_placeholder', 'llm_compose_helper') || '') + '"></textarea>' +
|
||||
'</div>';
|
||||
|
||||
var buttons = [
|
||||
{
|
||||
text: rcmail.gettext('rewrite_submit', 'llm_compose_helper'),
|
||||
classes: 'mainaction',
|
||||
click: function(e, ref) {
|
||||
var promptValue = document.getElementById(promptId).value || '';
|
||||
|
||||
// Show loading
|
||||
rcmail.set_busy(true, 'loading');
|
||||
|
||||
// Send to LLM API with message and prompt
|
||||
rcmail.http_post('plugin.llm_rewrite', {
|
||||
message: messageText,
|
||||
prompt: promptValue
|
||||
}, function() {
|
||||
rcmail.set_busy(false);
|
||||
});
|
||||
|
||||
if (ref && ref.hide) ref.hide();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: rcmail.gettext('rewrite_cancel', 'llm_compose_helper'),
|
||||
click: function(e, ref) { if (ref && ref.hide) ref.hide(); }
|
||||
}
|
||||
];
|
||||
|
||||
// Open Roundcube dialog
|
||||
rcmail.show_popup_dialog(dialogHtml, rcmail.gettext('rewrite_with_llm', 'llm_compose_helper'), buttons, {modal: true, width: 520});
|
||||
});
|
||||
});
|
||||
|
||||
// Handle response from server
|
||||
rcmail.addEventListener('plugin.llm_rewrite_response', function(response) {
|
||||
if (response.status === 'success' && response.text) {
|
||||
const newText = response.text;
|
||||
|
||||
if (rcmail.env.html_editor && rcmail.editor && rcmail.editor.setData) {
|
||||
rcmail.editor.setData(newText); // For CKEditor
|
||||
} else {
|
||||
$('#composebody').val(newText);
|
||||
}
|
||||
|
||||
rcmail.showMessage(rcmail.gettext('rewrite_success', 'llm_compose_helper'), 'confirmation');
|
||||
} else {
|
||||
var errorMsg = response && response.message ? String(response.message) : rcmail.gettext('rewrite_error', 'llm_compose_helper');
|
||||
rcmail.showMessage(errorMsg, 'error');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Extend `llm_compose_helper.php` – Add Server-Side Action
|
||||
|
||||
Update the `llm_compose_helper.php` file to register the AJAX action and handle the request:
|
||||
|
||||
```php
|
||||
function init()
|
||||
{
|
||||
$this->load_config();
|
||||
$this->add_texts('localization/', true);
|
||||
|
||||
$rcmail = rcmail::get_instance();
|
||||
if ($rcmail->action == 'compose') {
|
||||
$this->include_script('js/llm_compose_helper.js');
|
||||
$this->register_handler('plugin.llm_button', array($this, 'llm_button'));
|
||||
|
||||
// Register custom action for AJAX
|
||||
$this->register_action('plugin.llm_rewrite', array($this, 'action_handler'));
|
||||
}
|
||||
}
|
||||
|
||||
function action_handler()
|
||||
{
|
||||
$rcmail = rcmail::get_instance();
|
||||
|
||||
// Get input
|
||||
$message = rcube_utils::get_input_value('message', rcube_utils::INPUT_POST);
|
||||
$prompt = rcube_utils::get_input_value('prompt', rcube_utils::INPUT_POST);
|
||||
if (empty($message)) {
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'error',
|
||||
'message' => 'No message provided'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get config
|
||||
$api_url = $rcmail->config->get('llm_api_url');
|
||||
if (!$api_url) {
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'error',
|
||||
'message' => 'LLM API URL not configured'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = $rcmail->config->get('llm_api_headers', ['Content-Type: application/json']);
|
||||
|
||||
// Prepare request
|
||||
$data = json_encode(['text' => $message, 'prompt' => $prompt]);
|
||||
|
||||
// Use file_get_contents or cURL
|
||||
$options = [
|
||||
'http' => [
|
||||
'header' => $headers,
|
||||
'method' => 'POST',
|
||||
'content' => $data,
|
||||
'timeout' => 30
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'cafile' => '/etc/ssl/certs/ca-certificates.crt', // Adjust as needed
|
||||
]
|
||||
];
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($api_url, false, $context);
|
||||
|
||||
if ($result === false) {
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'error',
|
||||
'message' => 'Request failed'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$response = json_decode($result, true);
|
||||
$rewritten = isset($response['text']) && $response['text'] !== null ? $response['text'] : ($response['message'] ?? $result);
|
||||
|
||||
// Return success
|
||||
$rcmail->output->command('plugin.llm_rewrite_response', [
|
||||
'status' => 'success',
|
||||
'text' => $rewritten
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Example LLM API Response Format (Expected)
|
||||
|
||||
Your external LLM endpoint should accept POST JSON:
|
||||
|
||||
```json
|
||||
{ "text": "Hello, I am writing to ask about...", "prompt": "Make it shorter and more formal" }
|
||||
```
|
||||
|
||||
And return:
|
||||
|
||||
```json
|
||||
{ "text": "Hi, I hope you're doing well. I'm reaching out to inquire about..." }
|
||||
```
|
||||
|
||||
Ensure CORS is allowed if hosted separately.
|
||||
|
||||
---
|
||||
|
||||
## 7. Optional: Add More Labels
|
||||
|
||||
In `localization/en_US.inc`, add:
|
||||
|
||||
```php
|
||||
$messages = array(
|
||||
'rewrite_success' => 'Message rewritten successfully!',
|
||||
'rewrite_error' => 'Failed to rewrite message.',
|
||||
'non_empty' => 'Please write something before using AI rewrite.'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Enable the Plugin
|
||||
|
||||
In `config/config.inc.php`:
|
||||
|
||||
```php
|
||||
$config['plugins'] = array(
|
||||
// ... other plugins
|
||||
'llm_compose_helper'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final Result
|
||||
|
||||
When composing or replying:
|
||||
- A new button labeled "AI Rewrite" appears in the toolbar.
|
||||
- Clicking it opens a dialog asking for a rewrite prompt.
|
||||
- Submitting sends the current message and the prompt to your LLM API.
|
||||
- The response replaces the message body.
|
||||
- Works with plain text and HTML (via CKEditor if enabled).
|
||||
|
||||
43
scripts/jsx-loader.mjs
Normal file
43
scripts/jsx-loader.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
// ESM loader to transpile .jsx files on the fly using esbuild
|
||||
// Usage: node --loader ./scripts/jsx-loader.mjs cli-ink.js
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { transform } from 'esbuild';
|
||||
|
||||
/** @typedef {import('node:module').LoadHook} LoadHook */
|
||||
/** @typedef {import('node:module').ResolveHook} ResolveHook */
|
||||
|
||||
/** @type {ResolveHook} */
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
// Defer to Node's default resolver for most cases
|
||||
return nextResolve(specifier, context, nextResolve);
|
||||
}
|
||||
|
||||
/** @type {LoadHook} */
|
||||
export async function load(url, context, nextLoad) {
|
||||
// Handle .jsx sources
|
||||
if (url.endsWith('.jsx')) {
|
||||
const filename = fileURLToPath(url);
|
||||
const source = await readFile(filename, 'utf8');
|
||||
|
||||
const result = await transform(source, {
|
||||
loader: 'jsx',
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
sourcefile: filename,
|
||||
});
|
||||
|
||||
return {
|
||||
format: 'module',
|
||||
source: result.code,
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to default loader for everything else
|
||||
return nextLoad(url, context, nextLoad);
|
||||
}
|
||||
|
||||
|
||||
|
||||
9
scripts/register-jsx-loader.mjs
Normal file
9
scripts/register-jsx-loader.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
// Registers our custom JSX ESM loader using Node's stable register() API
|
||||
import { register } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// Resolve relative to project root cwd
|
||||
register('./scripts/jsx-loader.mjs', pathToFileURL('./'));
|
||||
|
||||
|
||||
|
||||
37
src/example.js
Normal file
37
src/example.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// example.js
|
||||
import StatVitTerm from './stat-vit-term.js';
|
||||
|
||||
const termSession = new StatVitTerm();
|
||||
|
||||
termSession.on('std', (data) => {
|
||||
process.stdout.write(data);
|
||||
});
|
||||
|
||||
termSession.on('err', (data) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
termSession.on('close', (code) => {
|
||||
console.log(`\nTerminal closed with code: ${code}`);
|
||||
});
|
||||
|
||||
termSession.on('ready', () => {
|
||||
console.log('Terminal ready\n');
|
||||
|
||||
// Ping starten
|
||||
console.log('--- Ping starten ---');
|
||||
termSession.input('ping 127.0.0.1\n');
|
||||
|
||||
// Nach 3 Sekunden abbrechen
|
||||
setTimeout(() => {
|
||||
console.log('\n--- Ping abbrechen mit ^C ---');
|
||||
termSession.input('^C');
|
||||
|
||||
}, 3000);
|
||||
|
||||
// Nach 4 Sekunden beenden
|
||||
setTimeout(() => {
|
||||
console.log('\n--- Terminal beenden ---');
|
||||
termSession.kill();
|
||||
}, 40000000);
|
||||
});
|
||||
45
src/stat-vit-term.js
Normal file
45
src/stat-vit-term.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/stat-vit-term.js
|
||||
|
||||
import pty from 'node-pty';
|
||||
|
||||
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
LANG: 'en_US.UTF-8',
|
||||
PWD: process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
// Handle output
|
||||
ptyProcess.onData((data) => {
|
||||
process.stdout.write(data); // Preserve formatting
|
||||
});
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`\n\n✨ Shell exited with code ${exitCode}${signal ? ` (via signal ${signal})` : ''}`);
|
||||
});
|
||||
|
||||
// Simulate input
|
||||
setTimeout(() => {
|
||||
console.log('\n[INPUT] Starting ping...');
|
||||
ptyProcess.write('ping 127.0.0.1\r');
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('\n[INPUT] Sending Ctrl+C...');
|
||||
ptyProcess.write('\x03'); // Ctrl+C
|
||||
}, 5000);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('\n[INPUT] Exiting shell...');
|
||||
ptyProcess.write('exit\r');
|
||||
}, 7000);
|
||||
136
src/terminalService.js
Normal file
136
src/terminalService.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import EventEmitter from 'events';
|
||||
import pty from 'node-pty';
|
||||
|
||||
class TerminalService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.lines = [];
|
||||
this.partial = '';
|
||||
this.ptyProcess = null;
|
||||
this.started = false;
|
||||
this.maxLines = 1000;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return;
|
||||
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
|
||||
const cols = process.stdout && process.stdout.columns ? process.stdout.columns : 120;
|
||||
const rows = process.stdout && process.stdout.rows ? process.stdout.rows : 30;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const userShell = process.env.SHELL && !isWindows ? process.env.SHELL : null;
|
||||
const shellPath = userShell || (isWindows ? 'powershell.exe' : '/bin/bash');
|
||||
const args = ['--rcfile','rc'];
|
||||
|
||||
this.ptyProcess = pty.spawn(shellPath, args, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
PS1: 'bash> '
|
||||
},
|
||||
});
|
||||
|
||||
this.ptyProcess.onData((data) => {
|
||||
const str = String(data);
|
||||
// Normalize CRLF to LF to avoid double-handling \r and \n
|
||||
const normalized = str.replace(/\r\n/g, '\n');
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
const ch = normalized[i];
|
||||
if (ch === '\n') {
|
||||
// Line feed completes the current line
|
||||
this.lines.push(this.partial);
|
||||
this.partial = '';
|
||||
} else if (ch === '\r') {
|
||||
// Standalone carriage return: simulate return to start of line (overwrite)
|
||||
this.partial = '';
|
||||
} else if (ch === '\b' || ch === '\x7f') {
|
||||
// Backspace or DEL: remove last char if present
|
||||
if (this.partial.length > 0) {
|
||||
this.partial = this.partial.slice(0, -1);
|
||||
}
|
||||
} else {
|
||||
this.partial += ch;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce max lines buffer
|
||||
if (this.lines.length > this.maxLines) {
|
||||
this.lines.splice(0, this.lines.length - this.maxLines);
|
||||
}
|
||||
|
||||
// Emit lines including current partial to ensure prompts (no trailing newline) are visible
|
||||
const display = this.partial ? [...this.lines, this.partial] : this.lines.slice();
|
||||
this.emit('update', display);
|
||||
});
|
||||
|
||||
// Resize with the host TTY
|
||||
const onResize = () => {
|
||||
try {
|
||||
const newCols = process.stdout.columns || cols;
|
||||
const newRows = process.stdout.rows || rows;
|
||||
this.ptyProcess.resize(newCols, newRows);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
if (process.stdout && process.stdout.on) {
|
||||
process.stdout.on('resize', onResize);
|
||||
}
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
this.emit('exit', { exitCode, signal });
|
||||
});
|
||||
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
getLines() {
|
||||
return this.lines.slice();
|
||||
}
|
||||
|
||||
write(input) {
|
||||
if (!this.ptyProcess) return;
|
||||
this.ptyProcess.write(input);
|
||||
}
|
||||
|
||||
resize(columns, rows) {
|
||||
if (!this.ptyProcess) return;
|
||||
try {
|
||||
const cols = Math.max(1, Number(columns) || 1);
|
||||
const r = rows ? Math.max(1, Number(rows) || 1) : undefined;
|
||||
if (r !== undefined) {
|
||||
this.ptyProcess.resize(cols, r);
|
||||
} else {
|
||||
this.ptyProcess.resize(cols, this.ptyProcess.rows || 24);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
try {
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.kill();
|
||||
this.ptyProcess = null;
|
||||
}
|
||||
this.started = false;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
restart() {
|
||||
try { this.dispose(); } catch {}
|
||||
try { this.start(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const terminalService = new TerminalService();
|
||||
export default terminalService;
|
||||
|
||||
|
||||
468
src/ui/InkApp.jsx
Normal file
468
src/ui/InkApp.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import uiService from './uiService.js';
|
||||
import TextInput from 'ink-text-input';
|
||||
import terminalService from '../terminalService.js';
|
||||
import ModelDialog from '../../modelDialog.js';
|
||||
|
||||
const sharedModelDialog = new ModelDialog();
|
||||
const npmSpinnerFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
||||
|
||||
class Pane extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cursorVisible: true,
|
||||
};
|
||||
this._cursorTimer = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
if (this.props.showCursor) {
|
||||
this._cursorTimer = setInterval(() => {
|
||||
this.setState((s) => ({ cursorVisible: !s.cursorVisible }));
|
||||
}, typeof this.props.cursorBlinkMs === 'number' && this.props.cursorBlinkMs > 0 ? this.props.cursorBlinkMs : 500);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this._cursorTimer) {
|
||||
clearInterval(this._cursorTimer);
|
||||
this._cursorTimer = null;
|
||||
}
|
||||
}
|
||||
// Strip ANSI escape sequences so width measurement/truncation is accurate
|
||||
stripAnsi(input) {
|
||||
if (input == null) return '';
|
||||
const str = String(input);
|
||||
const ansiPattern = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g; // ESC[ ... cmd
|
||||
return str.replace(ansiPattern, '');
|
||||
}
|
||||
// Expand tab stops to spaces using 8-column tabs (like a typical terminal)
|
||||
expandTabs(input, tabWidth, limit) {
|
||||
const width = typeof tabWidth === 'number' && tabWidth > 0 ? tabWidth : 8;
|
||||
const max = typeof limit === 'number' && limit > 0 ? limit : Infinity;
|
||||
let col = 0;
|
||||
let out = '';
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const ch = input[i];
|
||||
if (ch === '\t') {
|
||||
const spaces = width - (col % width);
|
||||
// If adding spaces exceeds max, clamp
|
||||
const add = Math.min(spaces, Math.max(0, max - col));
|
||||
out += ' '.repeat(add);
|
||||
col += add;
|
||||
if (col >= max) break;
|
||||
} else {
|
||||
// Treat as width 1 for simplicity (does not account for wide unicode)
|
||||
if (col + 1 > max) break;
|
||||
out += ch;
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Apply a blinking cursor to the given line according to width constraints
|
||||
withCursor(line, maxWidth) {
|
||||
const cursorChar = typeof this.props.cursorChar === 'string' && this.props.cursorChar.length > 0 ? this.props.cursorChar[0] : '█';
|
||||
const visible = !!this.state.cursorVisible;
|
||||
if (typeof maxWidth !== 'number' || maxWidth <= 0) {
|
||||
return line;
|
||||
}
|
||||
// Place cursor at the logical end of content, clamped to last column
|
||||
const width = maxWidth;
|
||||
const base = (line || '').slice(0, width);
|
||||
const cursorIndex = Math.min(base.length, width - 1);
|
||||
const targetLen = Math.min(width, cursorIndex + 1);
|
||||
const padLen = Math.max(0, targetLen - base.length);
|
||||
const padded = padLen > 0 ? `${base}${' '.repeat(padLen)}` : base.slice(0, targetLen);
|
||||
const chars = padded.split('');
|
||||
chars[cursorIndex] = visible ? cursorChar : ' ';
|
||||
return chars.join('');
|
||||
}
|
||||
render() {
|
||||
const { title, lines, maxWidth } = this.props;
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Text color="cyan">{title}</Text>
|
||||
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
|
||||
{(lines && lines.length > 0)
|
||||
? lines.map((line, index) => {
|
||||
const isLast = index === lines.length - 1;
|
||||
const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined;
|
||||
const clean = this.stripAnsi(line);
|
||||
const expanded = this.expandTabs(clean, 8, width);
|
||||
const baseLine = (width && expanded.length > width) ? expanded.slice(0, width) : expanded;
|
||||
const finalLine = (this.props.showCursor && isLast) ? this.withCursor(baseLine, width) : baseLine;
|
||||
return (
|
||||
<Text key={index}>{finalLine}</Text>
|
||||
);
|
||||
})
|
||||
: <Text dimColor>—</Text>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class InkApp extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
input: '',
|
||||
logs: [],
|
||||
terminal: [],
|
||||
chainOfThought: [],
|
||||
llmOutput: [],
|
||||
menuOpen: false,
|
||||
menuIndex: 0,
|
||||
model: 'gpt-5',
|
||||
reasoningEffort: 'minimal',
|
||||
outputVerbosity: 'low',
|
||||
isLoading: false,
|
||||
spinnerIndex: 0
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.setLLMOutput = this.setLLMOutput.bind(this);
|
||||
this.setChainOfThought = this.setChainOfThought.bind(this);
|
||||
this.setTerminal = this.setTerminal.bind(this);
|
||||
this.setLogs = this.setLogs.bind(this);
|
||||
this.toggleMenu = this.toggleMenu.bind(this);
|
||||
this.onKeypress = this.onKeypress.bind(this);
|
||||
this.menuAction = this.menuAction.bind(this);
|
||||
this.getModelSettingsItems = this.getModelSettingsItems.bind(this);
|
||||
this.handleModelSettingAdjust = this.handleModelSettingAdjust.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.terminalUnsub = (lines) => {
|
||||
this.setTerminal(lines);
|
||||
};
|
||||
terminalService.on('update', this.terminalUnsub);
|
||||
// initialize with current buffered output if any
|
||||
const initial = terminalService.getLines();
|
||||
if (initial && initial.length) {
|
||||
this.setTerminal(initial);
|
||||
}
|
||||
|
||||
// Resize PTY columns to match the Terminal pane width on start and on TTY resize
|
||||
this.onResize = () => {
|
||||
const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80;
|
||||
const columnWidth = Math.max(1, Math.floor(totalCols / 2));
|
||||
const paneContentWidth = Math.max(1, columnWidth - 4); // borders + padding
|
||||
terminalService.resize(paneContentWidth);
|
||||
};
|
||||
if (process.stdout && process.stdout.on) {
|
||||
process.stdout.on('resize', this.onResize);
|
||||
}
|
||||
this.onResize();
|
||||
|
||||
// Keyboard handling for menu
|
||||
if (process.stdin && process.stdin.on) {
|
||||
try { process.stdin.setRawMode(true); } catch {}
|
||||
process.stdin.on('data', this.onKeypress);
|
||||
}
|
||||
// spinner timer
|
||||
this._spinnerTimer = setInterval(() => {
|
||||
if (this.state.isLoading) {
|
||||
this.setState((s) => ({ spinnerIndex: (s.spinnerIndex + 1) % npmSpinnerFrames.length }));
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.terminalUnsub) {
|
||||
terminalService.off('update', this.terminalUnsub);
|
||||
this.terminalUnsub = null;
|
||||
}
|
||||
if (this.onResize && process.stdout && process.stdout.off) {
|
||||
process.stdout.off('resize', this.onResize);
|
||||
this.onResize = null;
|
||||
}
|
||||
if (process.stdin && process.stdin.off) {
|
||||
process.stdin.off('data', this.onKeypress);
|
||||
}
|
||||
if (this._spinnerTimer) {
|
||||
clearInterval(this._spinnerTimer);
|
||||
this._spinnerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setPaneLines(stateKey, lines) {
|
||||
if (typeof stateKey !== 'string' || !(stateKey in this.state)) {
|
||||
throw new Error(`Invalid state key: ${String(stateKey)}`);
|
||||
}
|
||||
if (lines === undefined) {
|
||||
this.setState({ [stateKey]: [] });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(lines)) {
|
||||
throw new TypeError(`Expected an array of lines or undefined for ${stateKey}`);
|
||||
}
|
||||
this.setState({ [stateKey]: lines });
|
||||
}
|
||||
|
||||
setLLMOutput(lines) {
|
||||
this.setPaneLines('llmOutput', lines);
|
||||
}
|
||||
|
||||
setChainOfThought(lines) {
|
||||
this.setPaneLines('chainOfThought', lines);
|
||||
}
|
||||
|
||||
setTerminal(lines) {
|
||||
this.setPaneLines('terminal', lines);
|
||||
}
|
||||
|
||||
setLogs(lines) {
|
||||
this.setPaneLines('logs', lines);
|
||||
}
|
||||
|
||||
handleChange(value) {
|
||||
this.setState({ input: value });
|
||||
}
|
||||
|
||||
async handleSubmit() {
|
||||
const { input } = this.state;
|
||||
if (!input) return;
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `> ${input}`],
|
||||
input: '',
|
||||
isLoading: true
|
||||
}));
|
||||
try {
|
||||
const result = await sharedModelDialog.interrogate(input);
|
||||
const finalOutput = Array.isArray(result && result.output) ? result.output : [String(result && result.output ? result.output : '')];
|
||||
const finalReasoning = Array.isArray(result && result.reasoning) ? result.reasoning : (result && result.reasoning ? [String(result.reasoning)] : []);
|
||||
// Append to LLM output with a separator, overwrite chain of thought
|
||||
this.setState((state) => ({
|
||||
llmOutput: [
|
||||
...state.llmOutput,
|
||||
...(state.llmOutput.length ? ['----------'] : []),
|
||||
...finalOutput
|
||||
]
|
||||
}));
|
||||
this.setChainOfThought(finalReasoning);
|
||||
this.setState((state) => ({
|
||||
logs: [
|
||||
...state.logs,
|
||||
`tokens input: ${JSON.stringify(result && result.inputTokens)}`,
|
||||
`tokens cached: ${JSON.stringify(result && result.cachedTokens)}`,
|
||||
`tokens output: ${JSON.stringify(result && result.outputTokens)}`
|
||||
]
|
||||
}));
|
||||
} catch (e) {
|
||||
this.setState((state) => ({
|
||||
logs: [...state.logs, `! interrogate error: ${String(e && e.message ? e.message : e)}`]
|
||||
}));
|
||||
} finally {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
toggleMenu(open) {
|
||||
this.setState((s) => ({ menuOpen: typeof open === 'boolean' ? open : !s.menuOpen }));
|
||||
}
|
||||
|
||||
onKeypress(buf) {
|
||||
const data = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf));
|
||||
// ESC [ A => Up arrow
|
||||
const isUp = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x41;
|
||||
const isDown = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x42;
|
||||
const isEnter = data.length === 1 && data[0] === 0x0d;
|
||||
const isLeft = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x44;
|
||||
const isRight = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x43;
|
||||
const isCtrlC = data.length === 1 && data[0] === 0x03;
|
||||
|
||||
if (!this.state.menuOpen) {
|
||||
if (isUp) {
|
||||
this.toggleMenu(true);
|
||||
return;
|
||||
}
|
||||
// let Ink TextInput handle normal typing; we don't intercept here
|
||||
if (isCtrlC) {
|
||||
// Pass through to exit behavior handled in cli-ink.js
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Submenu: Model settings adjustments
|
||||
if (this.state.menuOpen && this.state.menuMode === 'model') {
|
||||
const items = this.getModelSettingsItems();
|
||||
if (isUp) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isDown) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isLeft || isRight) {
|
||||
const idx = this.state.menuIndex;
|
||||
const dir = isRight ? 1 : -1;
|
||||
this.handleModelSettingAdjust(items[idx].key, dir);
|
||||
return;
|
||||
}
|
||||
if (isEnter) {
|
||||
// Enter exits model submenu back to main menu
|
||||
this.setState({ menuMode: undefined, menuIndex: 0 });
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Menu navigation (main menu)
|
||||
const items = this.getMenuItems();
|
||||
if (isUp) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isDown) {
|
||||
this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length }));
|
||||
return;
|
||||
}
|
||||
if (isEnter) {
|
||||
const idx = this.state.menuIndex;
|
||||
this.menuAction(items[idx]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getMenuItems() {
|
||||
return [
|
||||
'Send CTRL-C to terminal',
|
||||
'Restart Terminal',
|
||||
'Model settings',
|
||||
'Exit the app',
|
||||
'Close menu'
|
||||
];
|
||||
}
|
||||
|
||||
menuAction(label) {
|
||||
switch (label) {
|
||||
case 'Send CTRL-C to terminal':
|
||||
try { terminalService.write('\x03'); } catch {}
|
||||
break;
|
||||
case 'Restart Terminal':
|
||||
try { terminalService.restart(); } catch {}
|
||||
break;
|
||||
case 'Model settings':
|
||||
// Toggle a sub-menu state
|
||||
this.setState({ menuMode: 'model', menuIndex: 0 });
|
||||
break;
|
||||
case 'Exit the app':
|
||||
try { process.exit(0); } catch {}
|
||||
break;
|
||||
case 'Close menu':
|
||||
this.toggleMenu(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getModelSettingsItems() {
|
||||
return [
|
||||
{ key: 'model', label: 'Model', options: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano'] },
|
||||
{ key: 'reasoningEffort', label: 'Reasoning effort', options: ['minimal', 'low', 'medium', 'high'] },
|
||||
{ key: 'outputVerbosity', label: 'Output verbosity', options: ['low', 'medium', 'high'] },
|
||||
{ key: 'back', label: 'Back to main menu' }
|
||||
];
|
||||
}
|
||||
|
||||
handleModelSettingAdjust(key, dir) {
|
||||
if (key === 'back') {
|
||||
this.setState({ menuMode: undefined, menuIndex: 0 });
|
||||
return;
|
||||
}
|
||||
const items = this.getModelSettingsItems();
|
||||
const item = items.find((i) => i.key === key);
|
||||
if (!item || !item.options) return;
|
||||
const currentValue = this.state[key];
|
||||
const idx = item.options.indexOf(currentValue);
|
||||
const nextIdx = ((idx === -1 ? 0 : idx) + dir + item.options.length) % item.options.length;
|
||||
const nextValue = item.options[nextIdx];
|
||||
this.setState({ [key]: nextValue });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { input, logs, terminal, chainOfThought, llmOutput } = this.state;
|
||||
const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80;
|
||||
const totalRows = (process && process.stdout && process.stdout.rows) ? process.stdout.rows : 24;
|
||||
const columnWidth = Math.max(1, Math.floor(totalCols / 2));
|
||||
const paneContentWidth = Math.max(1, columnWidth - 4); // borders (2) + paddingX=1 on both sides (2)
|
||||
|
||||
// Compute how many lines fit vertically per pane
|
||||
// Reserve ~2 rows for the input area (label + margin) at bottom
|
||||
const reservedFooterRows = 2;
|
||||
const panesAreaRows = Math.max(4, totalRows - reservedFooterRows);
|
||||
const paneOuterHeight = Math.max(4, Math.floor(panesAreaRows / 2));
|
||||
// Remove top/bottom border (2) and title line (1)
|
||||
const paneContentHeight = Math.max(0, paneOuterHeight - 3);
|
||||
|
||||
const sliceLast = (arr) => {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
if (paneContentHeight <= 0) return [];
|
||||
if (arr.length <= paneContentHeight) return arr;
|
||||
return arr.slice(arr.length - paneContentHeight);
|
||||
};
|
||||
|
||||
const llmOutputView = sliceLast(llmOutput);
|
||||
const chainOfThoughtView = sliceLast(chainOfThought);
|
||||
const terminalView = sliceLast(terminal);
|
||||
const logsView = sliceLast(logs);
|
||||
|
||||
const menuItems = this.getMenuItems();
|
||||
const selected = this.state.menuIndex;
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box flexGrow={1} flexDirection="row" minWidth={0}>
|
||||
<Box flexGrow={1} flexDirection="column" minWidth={0}>
|
||||
<Pane title="LLM Output" lines={llmOutputView} maxWidth={paneContentWidth} />
|
||||
<Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column" minWidth={0}>
|
||||
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} showCursor cursorBlinkMs={600} />
|
||||
<Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
|
||||
</Box>
|
||||
</Box>
|
||||
{this.state.menuOpen && this.state.menuMode !== 'model' && (
|
||||
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
|
||||
<Text color="yellow">Main Menu (Up/Down to navigate, Enter to select)</Text>
|
||||
{menuItems.map((label, i) => (
|
||||
<Text key={label} color={i === selected ? 'cyan' : undefined}>
|
||||
{i === selected ? '› ' : ' '}{label}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{this.state.menuOpen && this.state.menuMode === 'model' && (
|
||||
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
|
||||
<Text color="yellow">Model Settings (Up/Down select, Left/Right change, Enter back)</Text>
|
||||
{this.getModelSettingsItems().map((item, i) => (
|
||||
<Text key={item.key || item.label} color={i === selected ? 'cyan' : undefined}>
|
||||
{i === selected ? '› ' : ' '}
|
||||
{item.label}{item.options ? `: ${this.state[item.key]}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>Input: </Text>
|
||||
{this.state.isLoading ? (
|
||||
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
|
||||
) : (
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
placeholder="Type and press Enter..."
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
src/ui/uiService.js
Normal file
9
src/ui/uiService.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import EventEmitter from 'events';
|
||||
|
||||
class UIService extends EventEmitter {}
|
||||
|
||||
const uiService = new UIService();
|
||||
export default uiService;
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
You are an interactive CLI AI assistant. Follow the user's instructions.
|
||||
If a tool is available and relevant, plan to use it.
|
||||
Be explicit when information is undefined.
|
||||
Do not silently fall back: surface errors.
|
||||
|
||||
Prefer concise answers.
|
||||
|
||||
Developer rules:
|
||||
- Null tells the truth. If data is missing/undefined, say so; do not invent values.
|
||||
- In development, never hide errors; include warnings if using fallbacks.
|
||||
|
||||
Behavior:
|
||||
- Answer succinctly.
|
||||
- Ask for clarification when the user input is ambiguous.
|
||||
- Output plain text suitable for a terminal.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const tests = [
|
||||
'tests/run-tests.js',
|
||||
'tests/run-readfile-tests.js',
|
||||
'tests/run-listfiles-tests.js',
|
||||
'tests/run-ripgrep-tests.js',
|
||||
];
|
||||
|
||||
function runOne(scriptPath) {
|
||||
return new Promise((resolve) => {
|
||||
const abs = path.resolve(process.cwd(), scriptPath);
|
||||
const child = spawn(process.execPath, [abs], { stdio: 'inherit' });
|
||||
child.on('close', (code) => resolve({ script: scriptPath, code }));
|
||||
child.on('error', (err) => resolve({ script: scriptPath, code: 1, error: err }));
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let anyFailed = false;
|
||||
for (const t of tests) {
|
||||
const res = await runOne(t);
|
||||
if (res.code !== 0) anyFailed = true;
|
||||
}
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('Fatal error in run-all:', err); process.exit(1); });
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { run as runListFiles } from '../tools/list_files.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const chrootRoot = '/home/seb/src/aiTools/tmp';
|
||||
const sandboxRoot = path.resolve(chrootRoot, 'listfiles-tests');
|
||||
|
||||
async function rimraf(dir) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeFiles(baseDir, filesMap) {
|
||||
for (const [rel, content] of Object.entries(filesMap || {})) {
|
||||
const filePath = path.resolve(baseDir, rel);
|
||||
await ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
function expectEqual(actual, expected, label) {
|
||||
const toStr = (v) => typeof v === 'string' ? v : JSON.stringify(v);
|
||||
if (toStr(actual) !== toStr(expected)) {
|
||||
const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<<undefined>>'));
|
||||
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(toStr(expected))}\nActual:\n${ellipsize(toStr(actual))}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCase(index, testCase) {
|
||||
const idx = String(index + 1).padStart(2, '0');
|
||||
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
|
||||
await ensureDir(chrootRoot);
|
||||
await rimraf(caseDir);
|
||||
await ensureDir(caseDir);
|
||||
|
||||
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
|
||||
|
||||
const args = await testCase.args({ dir: caseDir });
|
||||
const result = await runListFiles(args);
|
||||
|
||||
if (testCase.expect?.errorRegex) {
|
||||
if (!testCase.expect.errorRegex.test(result.err || '')) {
|
||||
throw new Error(`Error regex mismatch. Got: ${result.err}`);
|
||||
}
|
||||
} else {
|
||||
// Expect cwd and files
|
||||
let expectedCwd;
|
||||
if (testCase.expect.cwdFromArgs === true) {
|
||||
expectedCwd = (args.path === '' || args.path === '/') ? '.' : args.path;
|
||||
} else if (testCase.expect.cwdFromArgs === 'file') {
|
||||
expectedCwd = path.dirname(args.path || '.') || '.';
|
||||
} else {
|
||||
expectedCwd = testCase.expect.cwd;
|
||||
}
|
||||
expectEqual(result.cwd, expectedCwd, 'cwd');
|
||||
expectEqual(result.files, JSON.stringify(testCase.expect.files), 'files');
|
||||
}
|
||||
}
|
||||
|
||||
function cases() {
|
||||
const list = [];
|
||||
|
||||
// 1. List empty dir depth 0
|
||||
list.push({
|
||||
name: 'list empty dir depth 0',
|
||||
before: {},
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 0, includeHidden: false }),
|
||||
expect: { cwdFromArgs: true, files: [] }
|
||||
});
|
||||
|
||||
// 2. List single file
|
||||
list.push({
|
||||
name: 'list single file',
|
||||
before: { 'a.txt': 'A' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), depth: 1, includeHidden: false }),
|
||||
expect: { cwdFromArgs: 'file', files: [['a.txt', 'f', 1]] }
|
||||
});
|
||||
|
||||
// 3. Directory with nested structure depth 1
|
||||
list.push({
|
||||
name: 'nested depth 1',
|
||||
before: { 'sub/x.txt': 'X', 'sub/inner/y.txt': 'Y', 'z.txt': 'Z' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: false }),
|
||||
expect: { cwdFromArgs: true, files: [['sub', 'd', null], ['sub/inner', 'd', null], ['sub/x.txt', 'f', 1], ['z.txt', 'f', 1]] }
|
||||
});
|
||||
|
||||
// 4. Depth unlimited (-1)
|
||||
list.push({
|
||||
name: 'depth unlimited',
|
||||
before: { 'sub/x.txt': 'X', 'sub/inner/y.txt': 'Y', 'z.txt': 'Z' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: -1, includeHidden: false }),
|
||||
expect: { cwdFromArgs: true, files: [
|
||||
['sub', 'd', null],
|
||||
['sub/inner', 'd', null],
|
||||
['sub/inner/y.txt', 'f', 1],
|
||||
['sub/x.txt', 'f', 1],
|
||||
['z.txt', 'f', 1],
|
||||
] }
|
||||
});
|
||||
|
||||
// 5. Include hidden
|
||||
list.push({
|
||||
name: 'include hidden',
|
||||
before: { '.hidden': 'h', 'v.txt': 'v' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: true }),
|
||||
expect: { cwdFromArgs: true, files: [['.hidden', 'f', 1], ['v.txt', 'f', 1]] }
|
||||
});
|
||||
|
||||
// 6. Non-existent path -> error
|
||||
list.push({
|
||||
name: 'nonexistent path error',
|
||||
before: {},
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'nope')), depth: 1, includeHidden: false }),
|
||||
expect: { errorRegex: /Path does not exist:/ }
|
||||
});
|
||||
|
||||
// 7. Hidden excluded when includeHidden=false
|
||||
list.push({
|
||||
name: 'hidden excluded by default',
|
||||
before: { '.hidden': 'h', 'shown.txt': 's' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 1, includeHidden: false }),
|
||||
expect: { cwdFromArgs: true, files: [['shown.txt', 'f', 1]] }
|
||||
});
|
||||
|
||||
// 8. Depth 0 shows only top-level entries
|
||||
list.push({
|
||||
name: 'depth 0 top-level only',
|
||||
before: { 'a.txt': 'A', 'sub/b.txt': 'B' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, dir) || '/', depth: 0, includeHidden: false }),
|
||||
expect: { cwdFromArgs: true, files: [['a.txt', 'f', 1], ['sub', 'd', null]] }
|
||||
});
|
||||
|
||||
// 9. Pass hidden file path with includeHidden=false (excluded)
|
||||
list.push({
|
||||
name: 'hidden file path excluded when flag false',
|
||||
before: { '.only.txt': 'x' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, '.only.txt')), depth: 1, includeHidden: false }),
|
||||
expect: { cwdFromArgs: 'file', files: [] }
|
||||
});
|
||||
|
||||
// 10. Pass hidden file path with includeHidden=true (included)
|
||||
list.push({
|
||||
name: 'hidden file path included when flag true',
|
||||
before: { '.only.txt': 'x' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, '.only.txt')), depth: 1, includeHidden: true }),
|
||||
expect: { cwdFromArgs: 'file', files: [['.only.txt', 'f', 1]] }
|
||||
});
|
||||
|
||||
// 11. Path normalization outside chroot -> error
|
||||
list.push({
|
||||
name: 'outside chroot error',
|
||||
before: {},
|
||||
args: async () => ({ path: '../../etc', depth: 1, includeHidden: false }),
|
||||
expect: { errorRegex: /Path escapes chroot boundary/ }
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const all = cases();
|
||||
await ensureDir(sandboxRoot);
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const tc = all[i];
|
||||
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
|
||||
try {
|
||||
await runCase(i, tc);
|
||||
console.log(`✓ ${label}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.error(`✗ ${label}`);
|
||||
console.error(String(err?.stack || err));
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error in list_files test runner:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { run as runReadFile } from '../tools/read_file.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const chrootRoot = '/home/seb/src/aiTools/tmp';
|
||||
const sandboxRoot = path.resolve(chrootRoot, 'readfile-tests');
|
||||
|
||||
async function rimraf(dir) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeFiles(baseDir, filesMap) {
|
||||
for (const [rel, content] of Object.entries(filesMap || {})) {
|
||||
const filePath = path.resolve(baseDir, rel);
|
||||
await ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
function expectEqual(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<<undefined>>'));
|
||||
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected)}\nActual:\n${ellipsize(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectRegex(actual, re, label) {
|
||||
if (!re.test(actual)) {
|
||||
throw new Error(`${label} mismatch. Expected to match ${re}, Actual: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCase(index, testCase) {
|
||||
const idx = String(index + 1).padStart(2, '0');
|
||||
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
|
||||
await ensureDir(chrootRoot);
|
||||
await rimraf(caseDir);
|
||||
await ensureDir(caseDir);
|
||||
|
||||
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
|
||||
|
||||
const args = await testCase.args({ dir: caseDir });
|
||||
const result = await runReadFile(args);
|
||||
if (testCase.expect?.equals !== undefined) {
|
||||
expectEqual(result, testCase.expect.equals, 'Tool result');
|
||||
}
|
||||
if (testCase.expect?.errorRegex) {
|
||||
expectRegex(result, testCase.expect.errorRegex, 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
function cases() {
|
||||
const list = [];
|
||||
|
||||
// 1. Read entire small file
|
||||
list.push({
|
||||
name: 'read entire file',
|
||||
before: { 'a.txt': 'A\nB\nC' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 0, linesToRead: 400 }),
|
||||
expect: { equals: 'A\nB\nC' }
|
||||
});
|
||||
|
||||
// 2. Skip first line, read next 1
|
||||
list.push({
|
||||
name: 'skip and read one',
|
||||
before: { 'a.txt': 'A\nB\nC' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 1, linesToRead: 1 }),
|
||||
expect: { equals: 'B' }
|
||||
});
|
||||
|
||||
// 3. linesToRead 0 defaults to 400
|
||||
list.push({
|
||||
name: 'linesToRead zero defaults',
|
||||
before: { 'a.txt': 'L1\nL2\nL3' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'a.txt')), linesToSkip: 0, linesToRead: 0 }),
|
||||
expect: { equals: 'L1\nL2\nL3' }
|
||||
});
|
||||
|
||||
// 4. Missing file -> error string
|
||||
list.push({
|
||||
name: 'missing file error',
|
||||
before: {},
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'nope.txt')), linesToSkip: 0, linesToRead: 100 }),
|
||||
expect: { errorRegex: /read_file error:/ }
|
||||
});
|
||||
|
||||
// 5. Path outside chroot -> error
|
||||
list.push({
|
||||
name: 'path outside chroot error',
|
||||
before: {},
|
||||
args: async () => ({ path: '../../etc/passwd', linesToSkip: 0, linesToRead: 100 }),
|
||||
expect: { errorRegex: /read_file error: Path outside of allowed directory/ }
|
||||
});
|
||||
|
||||
// 6. Large file truncated to 400 lines
|
||||
list.push({
|
||||
name: 'truncate to 400 lines',
|
||||
before: async () => {
|
||||
const many = Array.from({ length: 450 }, (_, i) => `L${i + 1}`).join('\n');
|
||||
return { 'big.txt': many };
|
||||
},
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'big.txt')), linesToSkip: 0, linesToRead: 99999 }),
|
||||
expect: { equals: Array.from({ length: 400 }, (_, i) => `L${i + 1}`).join('\n') }
|
||||
});
|
||||
|
||||
// 7. Skip beyond file length -> empty
|
||||
list.push({
|
||||
name: 'skip beyond length returns empty',
|
||||
before: { 's.txt': 'A\nB' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 's.txt')), linesToSkip: 10, linesToRead: 5 }),
|
||||
expect: { equals: '' }
|
||||
});
|
||||
|
||||
// 8. Skip to last line and read one
|
||||
list.push({
|
||||
name: 'skip to last line and read one',
|
||||
before: { 't.txt': 'L1\nL2\nL3' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 't.txt')), linesToSkip: 2, linesToRead: 1 }),
|
||||
expect: { equals: 'L3' }
|
||||
});
|
||||
|
||||
// 9. Read exactly N lines from middle
|
||||
list.push({
|
||||
name: 'read middle two lines',
|
||||
before: { 'u.txt': 'A\nB\nC\nD' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'u.txt')), linesToSkip: 1, linesToRead: 2 }),
|
||||
expect: { equals: 'B\nC' }
|
||||
});
|
||||
|
||||
// 10. Empty file read -> empty string
|
||||
list.push({
|
||||
name: 'empty file read',
|
||||
before: { 'empty.txt': '' },
|
||||
args: async ({ dir }) => ({ path: path.relative(chrootRoot, path.join(dir, 'empty.txt')), linesToSkip: 0, linesToRead: 100 }),
|
||||
expect: { equals: '' }
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const all = cases();
|
||||
await ensureDir(sandboxRoot);
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const tc = all[i];
|
||||
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
|
||||
try {
|
||||
await runCase(i, tc);
|
||||
console.log(`✓ ${label}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.error(`✗ ${label}`);
|
||||
console.error(String(err?.stack || err));
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error in read_file test runner:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { promises as fs } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { run as runRipgrep } from '../tools/ripgrep.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const chrootRoot = '/home/seb/src/aiTools/tmp';
|
||||
const sandboxRoot = path.resolve(chrootRoot, 'rg-tests');
|
||||
|
||||
async function rimraf(dir) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeFiles(baseDir, filesMap) {
|
||||
for (const [rel, content] of Object.entries(filesMap || {})) {
|
||||
const filePath = path.resolve(baseDir, rel);
|
||||
await ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
function expectEqual(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
const ellipsize = (s) => (s && s.length > 400 ? s.slice(0, 400) + '…' : (s ?? '<<undefined>>'));
|
||||
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected)}\nActual:\n${ellipsize(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectRegex(actual, re, label) {
|
||||
if (!re.test(actual)) {
|
||||
throw new Error(`${label} mismatch. Expected to match ${re}, Actual: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCase(index, testCase) {
|
||||
const idx = String(index + 1).padStart(2, '0');
|
||||
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
|
||||
await ensureDir(chrootRoot);
|
||||
await rimraf(caseDir);
|
||||
await ensureDir(caseDir);
|
||||
|
||||
// Setup initial files
|
||||
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
|
||||
|
||||
const args = await testCase.args({ dir: caseDir });
|
||||
|
||||
let threw = false;
|
||||
let output = '';
|
||||
try {
|
||||
output = await runRipgrep(args);
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
output = err?.message || String(err);
|
||||
}
|
||||
|
||||
if (testCase.expect?.error) {
|
||||
if (!threw && typeof output === 'string') {
|
||||
// We expect error formatting to be returned as string starting with 'ripgrep error:'
|
||||
expectRegex(output, testCase.expect.error, 'Error string');
|
||||
} else if (threw) {
|
||||
expectRegex(output, testCase.expect.error, 'Thrown error');
|
||||
}
|
||||
} else {
|
||||
if (typeof testCase.expect?.equals === 'string') {
|
||||
expectEqual(output, testCase.expect.equals, 'Tool result');
|
||||
}
|
||||
if (typeof testCase.expect?.lineCount === 'number') {
|
||||
const lines = output ? output.split('\n').filter(Boolean) : [];
|
||||
expectEqual(lines.length, testCase.expect.lineCount, 'Line count');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cases() {
|
||||
const list = [];
|
||||
|
||||
// 1. Simple case-sensitive match
|
||||
list.push({
|
||||
name: 'simple case-sensitive match',
|
||||
before: { 'a.txt': 'Hello\nWorld\nhello again' },
|
||||
args: async ({ dir }) => ({ pattern: 'Hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '01-simple-case-sensitive-match/a.txt'))}:1:Hello` }
|
||||
});
|
||||
|
||||
// 2. Case-insensitive matches
|
||||
list.push({
|
||||
name: 'case-insensitive matches',
|
||||
before: { 'a.txt': 'Hello\nWorld\nhello again' },
|
||||
args: async ({ dir }) => ({ pattern: 'hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: true }),
|
||||
expect: { equals: [
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '02-case-insensitive-matches/a.txt'))}:1:Hello`,
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '02-case-insensitive-matches/a.txt'))}:3:hello again`
|
||||
].join('\n') }
|
||||
});
|
||||
|
||||
// 3. filePattern filter to subdir and extension
|
||||
list.push({
|
||||
name: 'filePattern filter',
|
||||
before: { 'sub/b.md': 'Alpha\nbeta\nGamma' },
|
||||
args: async ({ dir }) => ({ pattern: 'Alpha', filePattern: path.relative(chrootRoot, path.join(dir, 'sub/*.md')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '03-filepattern-filter/sub/b.md'))}:1:Alpha` }
|
||||
});
|
||||
|
||||
// 4. No matches -> empty string
|
||||
list.push({
|
||||
name: 'no matches returns empty',
|
||||
before: { 'a.txt': 'x\ny' },
|
||||
args: async ({ dir }) => ({ pattern: 'nomatch', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: '' }
|
||||
});
|
||||
|
||||
// 5. Output limited to 200 lines
|
||||
list.push({
|
||||
name: 'limit to 200 lines',
|
||||
before: async () => {
|
||||
const many = Array.from({ length: 250 }, (_, i) => `line${i + 1}`).join('\n');
|
||||
return { 'long.txt': many };
|
||||
},
|
||||
args: async ({ dir }) => ({ pattern: 'line', filePattern: path.relative(chrootRoot, path.join(dir, 'long.txt')), n_flag: true, i_flag: false }),
|
||||
expect: { lineCount: 200 }
|
||||
});
|
||||
|
||||
// 6. Invalid regex pattern -> error line
|
||||
list.push({
|
||||
name: 'invalid regex pattern',
|
||||
before: { 'a.txt': 'text' },
|
||||
args: async ({ dir }) => ({ pattern: '[', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { error: /ripgrep error:/ }
|
||||
});
|
||||
|
||||
// 7. No line numbers (n_flag false)
|
||||
list.push({
|
||||
name: 'no line numbers',
|
||||
before: { 'a.txt': 'foo\nbar\nfoo' },
|
||||
args: async ({ dir }) => ({ pattern: 'foo', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: false, i_flag: false }),
|
||||
expect: { equals: [
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '07-no-line-numbers/a.txt'))}:foo`,
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '07-no-line-numbers/a.txt'))}:foo`
|
||||
].join('\n') }
|
||||
});
|
||||
|
||||
// 8. filePattern include-only to exclude .md (tool supports single -g, so include *.txt)
|
||||
list.push({
|
||||
name: 'filePattern include-only excludes md',
|
||||
before: { 'a.txt': 'hit', 'b.md': 'hit' },
|
||||
args: async ({ dir }) => ({ pattern: 'hit', filePattern: path.relative(chrootRoot, path.join(dir, '**/*.txt')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '08-filepattern-negation-excludes-md/a.txt'))}:1:hit` }
|
||||
});
|
||||
|
||||
// 9. Empty filePattern searches all (we'll scope to the case dir by pattern and path shape)
|
||||
list.push({
|
||||
name: 'empty filePattern searches all',
|
||||
before: { 'x.js': 'Hello', 'y.txt': 'Hello' },
|
||||
args: async ({ dir }) => ({ pattern: 'Hello', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: [
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '09-empty-filepattern-searches-all/x.js'))}:1:Hello`,
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '09-empty-filepattern-searches-all/y.txt'))}:1:Hello`
|
||||
].join('\n') }
|
||||
});
|
||||
|
||||
// 10. Anchored regex
|
||||
list.push({
|
||||
name: 'anchored regex',
|
||||
before: { 'a.txt': 'Hello\nHello world\nHello' },
|
||||
args: async ({ dir }) => ({ pattern: '^Hello$', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: [
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '10-anchored-regex/a.txt'))}:1:Hello`,
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '10-anchored-regex/a.txt'))}:3:Hello`
|
||||
].join('\n') }
|
||||
});
|
||||
|
||||
// 11. Special regex characters
|
||||
list.push({
|
||||
name: 'special regex characters',
|
||||
before: { 'a.txt': 'a+b?c\\d and a+b?c\\d' },
|
||||
args: async ({ dir }) => ({ pattern: 'a\\+b\\?c\\\\d', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: `${path.relative(chrootRoot, path.join(sandboxRoot, '11-special-regex-characters/a.txt'))}:1:a+b?c\\d and a+b?c\\d` }
|
||||
});
|
||||
|
||||
// 12. Multiple files across dirs deterministic order
|
||||
list.push({
|
||||
name: 'multi dirs deterministic',
|
||||
before: { 'b/b.txt': 'X', 'a/a.txt': 'X' },
|
||||
args: async ({ dir }) => ({ pattern: '^X$', filePattern: path.relative(chrootRoot, path.join(dir, '**')), n_flag: true, i_flag: false }),
|
||||
expect: { equals: [
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '12-multi-dirs-deterministic/a/a.txt'))}:1:X`,
|
||||
`${path.relative(chrootRoot, path.join(sandboxRoot, '12-multi-dirs-deterministic/b/b.txt'))}:1:X`
|
||||
].join('\n') }
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const all = cases();
|
||||
await ensureDir(sandboxRoot);
|
||||
const results = [];
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const tc = all[i];
|
||||
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
|
||||
try {
|
||||
await runCase(i, tc);
|
||||
console.log(`✓ ${label}`);
|
||||
results.push({ name: tc.name, ok: true });
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.error(`✗ ${label}`);
|
||||
console.error(String(err?.stack || err));
|
||||
results.push({ name: tc.name, ok: false, error: String(err?.message || err) });
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error in ripgrep test runner:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { promises as fs } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { run as runPatch } from '../tools/patch_files.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const sandboxRoot = path.resolve(repoRoot, 'tmp', 'patch-tests');
|
||||
|
||||
async function rimraf(dir) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeFiles(baseDir, filesMap) {
|
||||
for (const [rel, content] of Object.entries(filesMap || {})) {
|
||||
const filePath = path.resolve(baseDir, rel);
|
||||
await ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileIfExists(filePath) {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPatch(lines, useCRLF = false) {
|
||||
const eol = useCRLF ? '\r\n' : '\n';
|
||||
return lines.join(eol);
|
||||
}
|
||||
|
||||
function begin() { return '*** Begin Patch'; }
|
||||
function end() { return '*** End Patch'; }
|
||||
function addFile(p) { return `*** Add File: ${p}`; }
|
||||
function updateFile(p) { return `*** Update File: ${p}`; }
|
||||
function moveTo(p) { return `*** Move to: ${p}`; }
|
||||
function endOfFile() { return '*** End of File'; }
|
||||
|
||||
function k(line) { return ` ${line}`; }
|
||||
function d(line) { return `-${line}`; }
|
||||
function a(line) { return `+${line}`; }
|
||||
function at(atLine = '') { return atLine ? `@@ ${atLine}` : '@@'; }
|
||||
|
||||
function slugify(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
function expectEqual(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
const ellipsize = (s) => (s.length > 400 ? s.slice(0, 400) + '…' : s);
|
||||
throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected ?? '<<undefined>>')}\nActual:\n${ellipsize(actual ?? '<<undefined>>')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCase(index, testCase) {
|
||||
const idx = String(index + 1).padStart(2, '0');
|
||||
const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`);
|
||||
await rimraf(caseDir);
|
||||
await ensureDir(caseDir);
|
||||
|
||||
// Setup initial files
|
||||
await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {}));
|
||||
|
||||
const patchText = await testCase.patch({ dir: caseDir });
|
||||
|
||||
let threw = false;
|
||||
let errorMessage = '';
|
||||
try {
|
||||
const result = await runPatch({ patch: patchText });
|
||||
if (testCase.expect?.resultEquals) {
|
||||
expectEqual(result, testCase.expect.resultEquals, 'Tool result');
|
||||
}
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
errorMessage = err?.message || String(err);
|
||||
}
|
||||
|
||||
if (testCase.expect?.error) {
|
||||
if (!threw) {
|
||||
throw new Error(`Expected error matching ${testCase.expect.error} but call succeeded`);
|
||||
}
|
||||
const re = typeof testCase.expect.error === 'string' ? new RegExp(testCase.expect.error.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : testCase.expect.error;
|
||||
if (!re.test(errorMessage)) {
|
||||
throw new Error(`Error did not match.\nExpected: ${re}\nActual: ${errorMessage}`);
|
||||
}
|
||||
} else if (threw) {
|
||||
throw new Error(`Unexpected error: ${errorMessage}`);
|
||||
}
|
||||
|
||||
if (testCase.expect?.files) {
|
||||
for (const [rel, expectedContent] of Object.entries(testCase.expect.files)) {
|
||||
const filePath = path.resolve(caseDir, rel);
|
||||
const content = await readFileIfExists(filePath);
|
||||
if (content === undefined) {
|
||||
throw new Error(`Expected file missing: ${rel}`);
|
||||
}
|
||||
expectEqual(content, expectedContent, `Content of ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (testCase.expect?.exists) {
|
||||
for (const rel of testCase.expect.exists) {
|
||||
const filePath = path.resolve(caseDir, rel);
|
||||
if (!fsSync.existsSync(filePath)) {
|
||||
throw new Error(`Expected path to exist: ${rel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (testCase.expect?.notExists) {
|
||||
for (const rel of testCase.expect.notExists) {
|
||||
const filePath = path.resolve(caseDir, rel);
|
||||
if (fsSync.existsSync(filePath)) {
|
||||
throw new Error(`Expected path NOT to exist: ${rel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cases() {
|
||||
const list = [];
|
||||
|
||||
// 1. Add simple file
|
||||
list.push({
|
||||
name: 'add simple file',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'file1.txt')),
|
||||
a('hello'),
|
||||
a('world'),
|
||||
end(),
|
||||
]),
|
||||
expect: {
|
||||
files: { 'file1.txt': 'hello\nworld' }
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Add nested directories
|
||||
list.push({
|
||||
name: 'add nested directories',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'a/b/c.txt')),
|
||||
a('alpha'),
|
||||
a('beta'),
|
||||
end(),
|
||||
]),
|
||||
expect: {
|
||||
files: { 'a/b/c.txt': 'alpha\nbeta' }
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Update simple replacement
|
||||
list.push({
|
||||
name: 'update simple replacement',
|
||||
before: { 'greet.txt': 'line1\nreplace me\nline3' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'greet.txt')),
|
||||
k('line1'),
|
||||
d('replace me'),
|
||||
a('replaced'),
|
||||
k('line3'),
|
||||
end(),
|
||||
]),
|
||||
expect: {
|
||||
files: { 'greet.txt': 'line1\nreplaced\nline3' }
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Update multiple chunks
|
||||
list.push({
|
||||
name: 'update multiple chunks',
|
||||
before: { 'multi.txt': 'l1\nl2\nl3\nl4\nl5\nl6\nl7' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'multi.txt')),
|
||||
k('l1'),
|
||||
k('l2'),
|
||||
d('l3'),
|
||||
a('L3'),
|
||||
k('l4'),
|
||||
k('l5'),
|
||||
d('l6'),
|
||||
a('L6'),
|
||||
k('l7'),
|
||||
end(),
|
||||
]),
|
||||
expect: {
|
||||
files: { 'multi.txt': 'l1\nl2\nL3\nl4\nl5\nL6\nl7' }
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Insert at beginning
|
||||
list.push({
|
||||
name: 'insert at beginning',
|
||||
before: { 'begin.txt': 'B1\nB2' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'begin.txt')),
|
||||
a('A0'),
|
||||
k('B1'),
|
||||
k('B2'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'begin.txt': 'A0\nB1\nB2' } }
|
||||
});
|
||||
|
||||
// 6. Insert at end with EOF marker
|
||||
list.push({
|
||||
name: 'insert at end with EOF',
|
||||
before: { 'end.txt': 'E1\nE2' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'end.txt')),
|
||||
k('E1'),
|
||||
k('E2'),
|
||||
a('E3'),
|
||||
endOfFile(),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'end.txt': 'E1\nE2\nE3' } }
|
||||
});
|
||||
|
||||
// 7. Delete a line
|
||||
list.push({
|
||||
name: 'delete a line',
|
||||
before: { 'delete.txt': 'X\nY\nZ' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'delete.txt')),
|
||||
k('X'),
|
||||
d('Y'),
|
||||
k('Z'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'delete.txt': 'X\nZ' } }
|
||||
});
|
||||
|
||||
// 8. Whitespace rstrip match
|
||||
list.push({
|
||||
name: 'rstrip whitespace match',
|
||||
before: { 'ws.txt': 'foo \nbar' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'ws.txt')),
|
||||
k('foo'), // matches 'foo ' via rstrip
|
||||
k('bar'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'ws.txt': 'foo \nbar' } }
|
||||
});
|
||||
|
||||
// 9. Trim match
|
||||
list.push({
|
||||
name: 'trim whitespace match',
|
||||
before: { 'trim.txt': ' alpha \n beta' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'trim.txt')),
|
||||
k('alpha'), // matches ' alpha ' via trim
|
||||
k(' beta'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'trim.txt': ' alpha \n beta' } }
|
||||
});
|
||||
|
||||
// 10. Use def_str to anchor (do not duplicate the anchor line in context)
|
||||
list.push({
|
||||
name: 'def_str anchor',
|
||||
before: { 'code.js': 'function a() {}\nfunction greet() {\n return 1;\n}\nfunction z() {}' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'code.js')),
|
||||
at('function greet() {'),
|
||||
// context starts at the next line after the def_str match
|
||||
d(' return 1;'),
|
||||
a(' return 2;'),
|
||||
k('}'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'code.js': 'function a() {}\nfunction greet() {\n return 2;\n}\nfunction z() {}' } }
|
||||
});
|
||||
|
||||
// 11. Bare @@ marker allowed
|
||||
list.push({
|
||||
name: 'bare @@ marker',
|
||||
before: { 'marker.txt': 'L1\nL2\nL3' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'marker.txt')),
|
||||
at(),
|
||||
k('L1'),
|
||||
d('L2'),
|
||||
a('X2'),
|
||||
k('L3'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'marker.txt': 'L1\nX2\nL3' } }
|
||||
});
|
||||
|
||||
// 12. Move/rename file with content change
|
||||
list.push({
|
||||
name: 'move and change',
|
||||
before: { 'mv.txt': 'A\nB' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'mv.txt')),
|
||||
moveTo(path.resolve(dir, 'moved/mv.txt')),
|
||||
k('A'),
|
||||
d('B'),
|
||||
a('C'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'moved/mv.txt': 'A\nC' }, notExists: ['mv.txt'] }
|
||||
});
|
||||
|
||||
// 13. Delete file
|
||||
list.push({
|
||||
name: 'delete file',
|
||||
before: { 'del/me.txt': 'bye' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
`*** Delete File: ${path.resolve(dir, 'del/me.txt')}`,
|
||||
end(),
|
||||
]),
|
||||
expect: { notExists: ['del/me.txt'] }
|
||||
});
|
||||
|
||||
// 14. Combined add/update/delete
|
||||
list.push({
|
||||
name: 'combined operations',
|
||||
before: { 'combo/u.txt': 'X\nY', 'combo/d.txt': 'gone' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'combo/u.txt')),
|
||||
k('X'),
|
||||
a('Z'),
|
||||
k('Y'),
|
||||
`*** Delete File: ${path.resolve(dir, 'combo/d.txt')}`,
|
||||
addFile(path.resolve(dir, 'combo/a.txt')),
|
||||
a('new'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'combo/u.txt': 'X\nZ\nY', 'combo/a.txt': 'new' }, notExists: ['combo/d.txt'] }
|
||||
});
|
||||
|
||||
// 15. Add with CRLF patch
|
||||
list.push({
|
||||
name: 'add with CRLF patch',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'crlf/add.txt')),
|
||||
a('one'),
|
||||
a('two'),
|
||||
end(),
|
||||
], true),
|
||||
expect: { files: { 'crlf/add.txt': 'one\ntwo' } }
|
||||
});
|
||||
|
||||
// 16. Update with CRLF patch
|
||||
list.push({
|
||||
name: 'update with CRLF patch',
|
||||
before: { 'crlf/up.txt': 'A\nB' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'crlf/up.txt')),
|
||||
k('A'),
|
||||
d('B'),
|
||||
a('C'),
|
||||
end(),
|
||||
], true),
|
||||
expect: { files: { 'crlf/up.txt': 'A\nC' } }
|
||||
});
|
||||
|
||||
// 17. Ambiguous content resolved by def_str
|
||||
list.push({
|
||||
name: 'ambiguous resolved by def_str',
|
||||
before: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 2;\n}' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'amb.js')),
|
||||
at('function target() {'),
|
||||
k('function target() {'),
|
||||
d(' let x = 2;'),
|
||||
a(' let x = 42;'),
|
||||
k('}'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 42;\n}' } }
|
||||
});
|
||||
|
||||
// 18. Update missing file -> error
|
||||
list.push({
|
||||
name: 'update missing file error',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'missing.txt')),
|
||||
k('x'),
|
||||
end(),
|
||||
]),
|
||||
expect: { error: /Patch error: Update File Error - missing file:/ }
|
||||
});
|
||||
|
||||
// 19. Delete missing file -> error
|
||||
list.push({
|
||||
name: 'delete missing file error',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
`*** Delete File: ${path.resolve(dir, 'nope.txt')}`,
|
||||
end(),
|
||||
]),
|
||||
expect: { error: /Patch error: Delete File Error - missing file:/ }
|
||||
});
|
||||
|
||||
// 20. Add existing file -> error
|
||||
list.push({
|
||||
name: 'add existing file error',
|
||||
before: { 'exists.txt': 'already' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'exists.txt')),
|
||||
a('new'),
|
||||
end(),
|
||||
]),
|
||||
expect: { error: /Patch error: Add File Error - file already exists:/ }
|
||||
});
|
||||
|
||||
// 21. Duplicate update -> error
|
||||
list.push({
|
||||
name: 'duplicate update error',
|
||||
before: { 'dup.txt': 'X' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'dup.txt')),
|
||||
k('X'),
|
||||
updateFile(path.resolve(dir, 'dup.txt')),
|
||||
k('X'),
|
||||
end(),
|
||||
]),
|
||||
expect: { error: /Patch error: Duplicate update for file:/ }
|
||||
});
|
||||
|
||||
// 22. Invalid line in update section -> error
|
||||
list.push({
|
||||
name: 'invalid line in section error',
|
||||
before: { 'bad.txt': 'A\nB' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'bad.txt')),
|
||||
k('A'),
|
||||
'?weird',
|
||||
k('B'),
|
||||
end(),
|
||||
]),
|
||||
expect: { error: /Patch error: Invalid Line:/ }
|
||||
});
|
||||
|
||||
// 23. Missing end sentinel -> error
|
||||
list.push({
|
||||
name: 'missing end sentinel error',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'x.txt')),
|
||||
a('x'),
|
||||
// Intentionally no end sentinel here; we'll strip it below
|
||||
]).replace(/\*\*\* End Patch$/, ''),
|
||||
expect: { error: /Patch error: Invalid patch text - missing sentinels/ }
|
||||
});
|
||||
|
||||
// 24. Unknown line while parsing -> error
|
||||
list.push({
|
||||
name: 'unknown line while parsing error',
|
||||
before: {},
|
||||
patch: async () => buildPatch([
|
||||
begin(),
|
||||
'some random line',
|
||||
end(),
|
||||
]),
|
||||
expect: { error: /Patch error: Unknown line while parsing:/ }
|
||||
});
|
||||
|
||||
// 25. Add empty file (no + lines)
|
||||
list.push({
|
||||
name: 'add empty file',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'empty.txt')),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'empty.txt': '' } }
|
||||
});
|
||||
|
||||
// 26. Replace whole file contents
|
||||
list.push({
|
||||
name: 'replace whole file',
|
||||
before: { 'whole.txt': 'a\nb\nc' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'whole.txt')),
|
||||
d('a'),
|
||||
d('b'),
|
||||
d('c'),
|
||||
a('x'),
|
||||
a('y'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'whole.txt': 'x\ny' } }
|
||||
});
|
||||
|
||||
// 27. Multiple file updates
|
||||
list.push({
|
||||
name: 'multi-file updates',
|
||||
before: { 'm1.txt': '1', 'm2.txt': 'A' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'm1.txt')),
|
||||
d('1'),
|
||||
a('2'),
|
||||
updateFile(path.resolve(dir, 'm2.txt')),
|
||||
d('A'),
|
||||
a('B'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'm1.txt': '2', 'm2.txt': 'B' } }
|
||||
});
|
||||
|
||||
// 28. Rename only (no content changes)
|
||||
list.push({
|
||||
name: 'rename only',
|
||||
before: { 'r/from.txt': 'same' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'r/from.txt')),
|
||||
moveTo(path.resolve(dir, 'r/to.txt')),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'r/to.txt': 'same' }, notExists: ['r/from.txt'] }
|
||||
});
|
||||
|
||||
// 29. EOF context matching at end
|
||||
list.push({
|
||||
name: 'EOF context matching',
|
||||
before: { 'eof.txt': 'first\nsecond\nthird' },
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
updateFile(path.resolve(dir, 'eof.txt')),
|
||||
k('second'),
|
||||
a('inserted'),
|
||||
k('third'),
|
||||
endOfFile(),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'eof.txt': 'first\nsecond\ninserted\nthird' } }
|
||||
});
|
||||
|
||||
// 30. Add multiple files in single patch
|
||||
list.push({
|
||||
name: 'add multiple files',
|
||||
before: {},
|
||||
patch: async ({ dir }) => buildPatch([
|
||||
begin(),
|
||||
addFile(path.resolve(dir, 'multi/a.txt')),
|
||||
a('A'),
|
||||
addFile(path.resolve(dir, 'multi/b.txt')),
|
||||
a('B'),
|
||||
addFile(path.resolve(dir, 'multi/c.txt')),
|
||||
a('C'),
|
||||
end(),
|
||||
]),
|
||||
expect: { files: { 'multi/a.txt': 'A', 'multi/b.txt': 'B', 'multi/c.txt': 'C' } }
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const all = cases();
|
||||
await ensureDir(sandboxRoot);
|
||||
const results = [];
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const tc = all[i];
|
||||
const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`;
|
||||
try {
|
||||
await runCase(i, tc);
|
||||
console.log(`✓ ${label}`);
|
||||
results.push({ name: tc.name, ok: true });
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.error(`✗ ${label}`);
|
||||
console.error(String(err?.stack || err));
|
||||
results.push({ name: tc.name, ok: false, error: String(err?.message || err) });
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error in test runner:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
2
todo.md
Normal file
2
todo.md
Normal file
@@ -0,0 +1,2 @@
|
||||
return the function call result via event.
|
||||
display function call evenst in logging
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Utility to normalize and validate paths within chroot
|
||||
// Utility to normalize and validate paths within a contained root directory
|
||||
const normalizePath = (inputPath, chrootDir) => {
|
||||
// Resolve chroot directory
|
||||
const chrootResolved = path.resolve(chrootDir);
|
||||
@@ -17,12 +17,19 @@ const normalizePath = (inputPath, chrootDir) => {
|
||||
|
||||
// Ensure the path is within chrootDir
|
||||
if (!normalized.startsWith(chrootResolved)) {
|
||||
throw new Error(`Path escapes chroot boundary: ${inputPath}`);
|
||||
throw new Error(`Path escapes root boundary: ${inputPath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Convert an absolute path under the contained root to a user-display path (root-relative)
|
||||
const toDisplayPath = (absPath, chrootDir) => {
|
||||
const rel = path.relative(path.resolve(chrootDir), absPath);
|
||||
if (!rel || rel === "") return "/";
|
||||
return `/${rel}`;
|
||||
};
|
||||
|
||||
// Main recursive directory listing function
|
||||
async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden = false) {
|
||||
const results = [];
|
||||
@@ -35,7 +42,7 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
|
||||
try {
|
||||
dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read directory: ${currentDir} (${err?.message || String(err)})`);
|
||||
throw new Error(`Failed to read directory: ${toDisplayPath(currentDir, chrootDir)}`);
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
@@ -91,39 +98,39 @@ async function listEntriesRecursive(startDir, chrootDir, maxDepth, includeHidden
|
||||
export default {
|
||||
type: "function",
|
||||
name: "list_files",
|
||||
description: "List files and directories recursively within a chroot directory with customizable options",
|
||||
description: "List files and directories recursively from the root with customizable options",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Directory or file path to list relative to chroot. Use '/' for the chroot root. Defaults to chroot root if not specified.",
|
||||
type: ["string", "null"],
|
||||
description: "Directory or file path relative to the root. Use '/' for the root. Defaults to root if not specified.",
|
||||
},
|
||||
depth: {
|
||||
type: "integer",
|
||||
type: ["integer", "null"],
|
||||
description: "Maximum subdirectory levels to traverse. Use -1 for unlimited depth. Defaults to 1.",
|
||||
minimum: -1,
|
||||
},
|
||||
includeHidden: {
|
||||
type: "boolean",
|
||||
type: ["boolean", "null"],
|
||||
description: "Whether to include hidden files and directories (starting with '.'). Defaults to false.",
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
required: ["path", "depth", "includeHidden"],
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: true,
|
||||
strict: false,
|
||||
};
|
||||
|
||||
export async function run(args) {
|
||||
const inputPath = args?.path || "";
|
||||
const depth = Number.isInteger(args?.depth) ? args.depth : 1;
|
||||
const includeHidden = args?.includeHidden ?? false;
|
||||
const chrootPath = '/home/seb/src/aiTools/tmp';
|
||||
const chrootPath = '/workspaces/aiTools/root';
|
||||
|
||||
if (!chrootPath) {
|
||||
return { err: "Chroot path is required" };
|
||||
return { err: "Root path is required" };
|
||||
}
|
||||
if (depth < -1) {
|
||||
return { err: `Depth must be >= -1, received ${args?.depth}` };
|
||||
@@ -135,7 +142,7 @@ export async function run(args) {
|
||||
// Auto-create the chroot base directory if it does not exist
|
||||
await fs.mkdir(chrootResolved, { recursive: true });
|
||||
} catch (err) {
|
||||
return { err: `Failed to prepare chroot path: ${chrootPath} (${err?.message || String(err)})` };
|
||||
return { err: "Failed to initialize root directory" };
|
||||
}
|
||||
|
||||
let resolvedBase;
|
||||
@@ -149,10 +156,13 @@ export async function run(args) {
|
||||
try {
|
||||
stat = await fs.lstat(resolvedBase);
|
||||
} catch (err) {
|
||||
return { err: `Path does not exist: ${resolvedBase} (${err?.message || String(err)})` };
|
||||
return { err: `Path does not exist${inputPath ? `: ${inputPath}` : ""}` };
|
||||
}
|
||||
|
||||
const cwd = path.relative(chrootResolved, stat.isFile() ? path.dirname(resolvedBase) : resolvedBase) || ".";
|
||||
const cwd = toDisplayPath(
|
||||
stat.isFile() ? path.dirname(resolvedBase) : resolvedBase,
|
||||
chrootResolved
|
||||
);
|
||||
|
||||
// Handle single file case
|
||||
if (stat.isFile()) {
|
||||
@@ -160,22 +170,27 @@ export async function run(args) {
|
||||
if (!includeHidden && fileName.startsWith(".")) {
|
||||
return { cwd, files: [] };
|
||||
}
|
||||
return { cwd, files: JSON.stringify([[fileName, 'f', stat.size]]) };
|
||||
// Return structured object for easier machine parsing
|
||||
return { cwd, files: [{ path: fileName, type: 'f', size: stat.size }] };
|
||||
}
|
||||
|
||||
// Handle non-directory case
|
||||
if (!stat.isDirectory()) {
|
||||
return { err: `Not a file or directory: ${resolvedBase}` };
|
||||
return { err: `Not a file or directory${inputPath ? `: ${inputPath}` : ""}` };
|
||||
}
|
||||
|
||||
// Handle directory case
|
||||
try {
|
||||
const files = await listEntriesRecursive(resolvedBase, chrootResolved, depth === -1 ? Infinity : depth, includeHidden);
|
||||
// Map to structured objects and sort by path for consistent output
|
||||
const mapped = files
|
||||
.map(([p, t, s]) => ({ path: p, type: t, size: s }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return {
|
||||
cwd,
|
||||
files: JSON.stringify(files.sort((a, b) => a[0].localeCompare(b[0]))), // Sort for consistent output
|
||||
files: mapped,
|
||||
};
|
||||
} catch (err) {
|
||||
return { err: `Failed to list files: ${err?.message || String(err)}` };
|
||||
return { err: "Failed to list files" };
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
const desc = `
|
||||
This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file,
|
||||
but the format of the diff specification is unique to this task, so pay careful attention to these instructions.
|
||||
To use the 'apply_patch' command, you should pass a message of the following structure as "input":
|
||||
|
||||
*** Begin Patch
|
||||
[YOUR_PATH]
|
||||
[YOUR_PATCH]
|
||||
*** End Patch
|
||||
|
||||
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
|
||||
@@ -36,8 +38,17 @@ For instructions on [context_before] and [context_after]:
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
|
||||
`;
|
||||
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code.
|
||||
|
||||
Complese Example:
|
||||
*** Begin Patch
|
||||
*** Add File: /test.js
|
||||
+ function method() {
|
||||
+ console.log("Hello, world!");
|
||||
+ }
|
||||
*** End Patch
|
||||
`;
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Domain objects
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -117,10 +128,10 @@ function resolvePath(chroot, filepath) {
|
||||
|
||||
const root = normalizePath(chroot);
|
||||
|
||||
// If file is absolute, use it as-is (after normalization).
|
||||
// We assume the caller ensures it is inside the chroot.
|
||||
// If file is absolute, resolve it under the chroot rather than using host FS root
|
||||
if (file.startsWith('/')) {
|
||||
return file;
|
||||
const resolvedAbs = joinPaths(root, file);
|
||||
return resolvedAbs.startsWith('/') ? resolvedAbs : '/' + resolvedAbs;
|
||||
}
|
||||
|
||||
// If file is relative, join with chroot
|
||||
@@ -229,9 +240,9 @@ class Parser {
|
||||
if (path) {
|
||||
// Resolve path with chroot
|
||||
const resolvedPath = resolvePath(this.chroot, path);
|
||||
|
||||
if (resolvedPath in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate update for file: ${resolvedPath}`);
|
||||
// Use user-specified path as the key to avoid double-resolving later
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate update for file: ${path}`);
|
||||
}
|
||||
const move_to = this.read_str("*** Move to: ");
|
||||
if (!(resolvedPath in this.current_files)) {
|
||||
@@ -239,8 +250,8 @@ class Parser {
|
||||
}
|
||||
const text = this.current_files[resolvedPath];
|
||||
const action = this._parse_update_file(text);
|
||||
action.move_path = move_to ? resolvePath(this.chroot, move_to) : null;
|
||||
this.patch.actions[resolvedPath] = action;
|
||||
action.move_path = move_to ? move_to : null;
|
||||
this.patch.actions[path] = action;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -249,14 +260,13 @@ class Parser {
|
||||
if (path) {
|
||||
// Resolve path with chroot
|
||||
const resolvedPath = resolvePath(this.chroot, path);
|
||||
|
||||
if (resolvedPath in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate delete for file: ${path}`);
|
||||
}
|
||||
if (!(resolvedPath in this.current_files)) {
|
||||
throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`);
|
||||
throw new DiffError(`Delete File Error - missing file: ${path}`);
|
||||
}
|
||||
this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE);
|
||||
this.patch.actions[path] = new PatchAction(ActionType.DELETE);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -265,14 +275,13 @@ class Parser {
|
||||
if (path) {
|
||||
// Resolve path with chroot
|
||||
const resolvedPath = resolvePath(this.chroot, path);
|
||||
|
||||
if (resolvedPath in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate add for file: ${path}`);
|
||||
}
|
||||
if (resolvedPath in this.current_files) {
|
||||
throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`);
|
||||
throw new DiffError(`Add File Error - file already exists: ${path}`);
|
||||
}
|
||||
this.patch.actions[resolvedPath] = this._parse_add_file();
|
||||
this.patch.actions[path] = this._parse_add_file();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -361,7 +370,9 @@ class Parser {
|
||||
if (!s.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File line (missing '+'): ${s}`);
|
||||
}
|
||||
lines.push(s.substring(1)); // strip leading '+'
|
||||
// Strip leading '+' and ignore a single optional space immediately after '+'
|
||||
const content = s.substring(1).replace(/^ /, "");
|
||||
lines.push(content);
|
||||
}
|
||||
return new PatchAction(ActionType.ADD, lines.join("\n"));
|
||||
}
|
||||
@@ -603,10 +614,11 @@ function _get_updated_file(text, action, path) {
|
||||
function patch_to_commit(patch, orig, chroot = null) {
|
||||
const commit = new Commit();
|
||||
for (const [path, action] of Object.entries(patch.actions)) {
|
||||
const resolvedPath = resolvePath(chroot, path);
|
||||
if (action.type === ActionType.DELETE) {
|
||||
commit.changes[path] = new FileChange(
|
||||
ActionType.DELETE,
|
||||
orig[path],
|
||||
orig[resolvedPath],
|
||||
null,
|
||||
null
|
||||
);
|
||||
@@ -621,11 +633,11 @@ function patch_to_commit(patch, orig, chroot = null) {
|
||||
null
|
||||
);
|
||||
} else if (action.type === ActionType.UPDATE) {
|
||||
const new_content = _get_updated_file(orig[path], action, path);
|
||||
const new_content = _get_updated_file(orig[resolvedPath], action, path);
|
||||
const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
|
||||
commit.changes[path] = new FileChange(
|
||||
ActionType.UPDATE,
|
||||
orig[path],
|
||||
orig[resolvedPath],
|
||||
new_content,
|
||||
move_path
|
||||
);
|
||||
@@ -721,20 +733,23 @@ function load_files(paths, open_fn) {
|
||||
function apply_commit(commit, write_fn, remove_fn, chroot = null) {
|
||||
for (const [path, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === ActionType.DELETE) {
|
||||
remove_fn(path);
|
||||
const target = resolvePath(chroot, path);
|
||||
remove_fn(target);
|
||||
} else if (change.type === ActionType.ADD) {
|
||||
if (change.new_content === null) {
|
||||
throw new DiffError(`ADD change for ${path} has no content`);
|
||||
}
|
||||
write_fn(path, change.new_content);
|
||||
const target = resolvePath(chroot, path);
|
||||
write_fn(target, change.new_content);
|
||||
} else if (change.type === ActionType.UPDATE) {
|
||||
if (change.new_content === null) {
|
||||
throw new DiffError(`UPDATE change for ${path} has no new content`);
|
||||
}
|
||||
const target = change.move_path ? resolvePath(chroot, change.move_path) : path;
|
||||
const target = change.move_path ? resolvePath(chroot, change.move_path) : resolvePath(chroot, path);
|
||||
write_fn(target, change.new_content);
|
||||
if (change.move_path) {
|
||||
remove_fn(path);
|
||||
const source = resolvePath(chroot, path);
|
||||
remove_fn(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -786,7 +801,7 @@ function remove_file(filepath) {
|
||||
export default {
|
||||
type: "function",
|
||||
name: "patch_files",
|
||||
description: "Apply a unified diff patch " + desc,
|
||||
description: "Generic Text File Editor create,edit,delete - Apply a unified diff patch " + desc,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -808,10 +823,11 @@ export async function run(args) {
|
||||
open_file,
|
||||
write_file,
|
||||
remove_file,
|
||||
'/home/seb/src/aiTools/tmp'
|
||||
'/workspaces/aiTools/root'
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.log(chalk.red('Patch error:'),error);
|
||||
return `Patch error: ${error.message}`
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const virtual_chroot = '/home/seb/src/aiTools/tmp';
|
||||
const virtual_chroot = '/workspaces/aiTools/root';
|
||||
|
||||
// Ensures reads are confined to `virtual_chroot`.
|
||||
|
||||
export default {
|
||||
type: "function", name: "read_file", description: "read a file", strict: true,
|
||||
type: "function", name: "read_file", description: "read a file", strict: false,
|
||||
parameters: {
|
||||
type: "object", required: ["path","linesToSkip","linesToRead"], additionalProperties: false, properties: {
|
||||
type: "object", required: ["path"], additionalProperties: false, properties: {
|
||||
path: { type: "string", description: "The path to the file to read.", },
|
||||
linesToSkip: { type: "integer", description: "The number of lines to skip. Use 0 to read from the beginning.", minimum: 0 },
|
||||
linesToRead: { type: "integer", description: "1-400 The number of lines to read. Use 0 or more than 400 to read 400 lines.", minimum: 0 }
|
||||
linesToSkip: { type: ["integer", "null"], description: "The number of lines to skip. Use 0 to read from the beginning, which is the default.", minimum: 0 },
|
||||
linesToRead: { type: ["integer", "null"], description: "1-400 The number of lines to read. 400 is the default.", minimum: 1, maximum: 400 }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -29,6 +29,11 @@ export async function run(args) {
|
||||
|
||||
// Normalize linesToRead (1-400, with 0 or >400 meaning 400)
|
||||
const maxLines = (linesToRead <= 0 || linesToRead > 400) ? 400 : linesToRead;
|
||||
|
||||
// check if the file exists
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return `read_file error: File does not exist`;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -51,7 +56,7 @@ export async function run(args) {
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
return 'Filecontent: ´´´'+lines.join('\n')+'´´´';
|
||||
} catch (error) {
|
||||
return `read_file error: ${error.message}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const virtual_chroot = '/home/seb/src/aiTools/tmp';
|
||||
const virtual_chroot = '/workspaces/aiTools/root';
|
||||
|
||||
export default {
|
||||
type: "function", name: "ripgrep", strict: true,
|
||||
|
||||
35
tools/websearch.js
Normal file
35
tools/websearch.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'dotenv/config';
|
||||
import Exa from "exa-js";
|
||||
|
||||
const exaApiKey = process.env.EXA_API_KEY;
|
||||
if (!exaApiKey) {
|
||||
throw new Error("Missing EXA_API_KEY environment variable for websearch");
|
||||
}
|
||||
const exa = new Exa(exaApiKey);
|
||||
|
||||
export default {
|
||||
type: 'function',
|
||||
name: 'websearch',
|
||||
description: 'Perform a google web search.',
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string', description: 'The search query.' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function run(args) {
|
||||
try
|
||||
{
|
||||
console.log('Google search: ', args.query);
|
||||
const result = await exa.search( args.query,{ type: "auto", userLocation: "DE", numResults: 20} );
|
||||
console.log('Google search result: ', result.results[0]);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return `websearch error: ${error?.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
30
tools/wget.js
Normal file
30
tools/wget.js
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
|
||||
export async function run(args){
|
||||
const { url } = params;
|
||||
if (!url) throw new Error('missing url');
|
||||
const res = await fetch(url);
|
||||
const buffer = await res.buffer();
|
||||
const filename = new Date().getTime() + '.' + url.split('.').pop();
|
||||
const content = buffer.slice(0, 500).toString('utf8');
|
||||
// save the file to the chroot
|
||||
const filePath = `/workspaces/aiTools/root/${filename}`;
|
||||
fs.writeFileSync(filePath, content);
|
||||
return { 'Downloaded to:': filename };
|
||||
};
|
||||
|
||||
// metadata for the tool runner
|
||||
export default {
|
||||
type: 'function',
|
||||
name: 'wget',
|
||||
description: 'Download URL to filesystem',
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['url'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The url to get.' }
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user