markov-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/markov.js +4 -0
- package/package.json +30 -0
- package/src/auth.js +35 -0
- package/src/editor.js +173 -0
- package/src/files.js +54 -0
- package/src/input.js +194 -0
- package/src/interactive.js +278 -0
- package/src/ollama.js +71 -0
- package/src/ui/logo.js +27 -0
- package/src/ui/picker.js +31 -0
package/bin/markov.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "markov-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A friendly CLI tool",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"markov-cli": "./bin/markov.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/markov-cli.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cli",
|
|
14
|
+
"markov",
|
|
15
|
+
"markov-cli"
|
|
16
|
+
],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src",
|
|
22
|
+
".env.example"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"chalk": "^5.6.2",
|
|
26
|
+
"commander": "^14.0.3",
|
|
27
|
+
"dotenv": "^16.4.5",
|
|
28
|
+
"gradient-string": "^3.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.markov');
|
|
6
|
+
const TOKEN_PATH = join(CONFIG_DIR, 'token');
|
|
7
|
+
|
|
8
|
+
export const API_URL = 'https://api.livingcloud.app/api';
|
|
9
|
+
|
|
10
|
+
export function getToken() {
|
|
11
|
+
if (!existsSync(TOKEN_PATH)) return null;
|
|
12
|
+
return readFileSync(TOKEN_PATH, 'utf-8').trim() || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveToken(token) {
|
|
16
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
17
|
+
writeFileSync(TOKEN_PATH, token, 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function login(email, password) {
|
|
21
|
+
const res = await fetch(`${API_URL}/auth/login`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ email, password }),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const body = await res.text().catch(() => '');
|
|
28
|
+
throw new Error(`Login failed (${res.status})${body ? ': ' + body : ''}`);
|
|
29
|
+
}
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
const token = data.data?.token ?? data.token ?? data.accessToken ?? data.access_token;
|
|
32
|
+
if (!token) throw new Error('No token received from server');
|
|
33
|
+
saveToken(token);
|
|
34
|
+
return token;
|
|
35
|
+
}
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
// Maps common language identifiers (including short aliases) to file extensions.
|
|
6
|
+
const LANG_TO_EXT = {
|
|
7
|
+
typescript: ['.ts', '.tsx'],
|
|
8
|
+
ts: ['.ts', '.tsx'],
|
|
9
|
+
tsx: ['.tsx'],
|
|
10
|
+
javascript: ['.js', '.jsx', '.mjs', '.cjs'],
|
|
11
|
+
js: ['.js', '.jsx', '.mjs', '.cjs'],
|
|
12
|
+
jsx: ['.jsx'],
|
|
13
|
+
python: ['.py'],
|
|
14
|
+
py: ['.py'],
|
|
15
|
+
css: ['.css'],
|
|
16
|
+
scss: ['.scss'],
|
|
17
|
+
html: ['.html'],
|
|
18
|
+
json: ['.json'],
|
|
19
|
+
bash: ['.sh'],
|
|
20
|
+
sh: ['.sh'],
|
|
21
|
+
markdown: ['.md'],
|
|
22
|
+
md: ['.md'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// --- Diff engine (LCS-based) ---
|
|
26
|
+
|
|
27
|
+
function lcs(a, b) {
|
|
28
|
+
const m = a.length, n = b.length;
|
|
29
|
+
const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
|
|
30
|
+
for (let i = 1; i <= m; i++)
|
|
31
|
+
for (let j = 1; j <= n; j++)
|
|
32
|
+
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
|
|
33
|
+
|
|
34
|
+
const result = [];
|
|
35
|
+
let i = m, j = n;
|
|
36
|
+
while (i > 0 || j > 0) {
|
|
37
|
+
if (i > 0 && j > 0 && a[i-1] === b[j-1]) {
|
|
38
|
+
result.unshift({ type: 'equal', line: a[i-1] }); i--; j--;
|
|
39
|
+
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
|
|
40
|
+
result.unshift({ type: 'add', line: b[j-1] }); j--;
|
|
41
|
+
} else {
|
|
42
|
+
result.unshift({ type: 'del', line: a[i-1] }); i--;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CONTEXT = 3; // unchanged lines to show around each change
|
|
49
|
+
|
|
50
|
+
function buildHunks(diff) {
|
|
51
|
+
// Mark which equal lines are within CONTEXT of a change
|
|
52
|
+
const changed = diff.map(d => d.type !== 'equal');
|
|
53
|
+
const show = diff.map((_, i) => {
|
|
54
|
+
if (changed[i]) return true;
|
|
55
|
+
for (let d = 1; d <= CONTEXT; d++)
|
|
56
|
+
if (changed[i - d] || changed[i + d]) return true;
|
|
57
|
+
return false;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const hunks = [];
|
|
61
|
+
let hunk = null;
|
|
62
|
+
for (let i = 0; i < diff.length; i++) {
|
|
63
|
+
if (!show[i]) {
|
|
64
|
+
if (hunk) { hunks.push(hunk); hunk = null; }
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!hunk) hunk = [];
|
|
68
|
+
hunk.push(diff[i]);
|
|
69
|
+
}
|
|
70
|
+
if (hunk) hunks.push(hunk);
|
|
71
|
+
return hunks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Renders a colour diff to stdout and returns { added, removed } counts.
|
|
76
|
+
*/
|
|
77
|
+
export function renderDiff(filepath, newContent, cwd = process.cwd()) {
|
|
78
|
+
const fullPath = join(cwd, filepath);
|
|
79
|
+
const oldContent = existsSync(fullPath) ? readFileSync(fullPath, 'utf-8') : '';
|
|
80
|
+
const oldLines = oldContent.split('\n');
|
|
81
|
+
const newLines = newContent.split('\n');
|
|
82
|
+
|
|
83
|
+
const diff = lcs(oldLines, newLines);
|
|
84
|
+
const hunks = buildHunks(diff);
|
|
85
|
+
|
|
86
|
+
const w = process.stdout.columns || 80;
|
|
87
|
+
const header = ` ✏ ${filepath} `;
|
|
88
|
+
const pad = Math.max(0, w - header.length);
|
|
89
|
+
const lpad = Math.floor(pad / 2);
|
|
90
|
+
const rpad = pad - lpad;
|
|
91
|
+
|
|
92
|
+
console.log('\n' + chalk.dim('─'.repeat(lpad)) + chalk.bold.white(header) + chalk.dim('─'.repeat(rpad)));
|
|
93
|
+
|
|
94
|
+
let added = 0, removed = 0;
|
|
95
|
+
let oldLine = 1, newLine = 1;
|
|
96
|
+
|
|
97
|
+
// Recalculate line numbers
|
|
98
|
+
const lineNums = [];
|
|
99
|
+
{ let o = 1, n = 1;
|
|
100
|
+
for (const d of diff) {
|
|
101
|
+
if (d.type === 'equal') { lineNums.push({ o, n }); o++; n++; }
|
|
102
|
+
else if (d.type === 'del') { lineNums.push({ o, n: null }); o++; }
|
|
103
|
+
else { lineNums.push({ o: null, n }); n++; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let diffIdx = 0;
|
|
108
|
+
for (const hunk of hunks) {
|
|
109
|
+
// Find start in full diff
|
|
110
|
+
const startIdx = diff.indexOf(hunk[0]);
|
|
111
|
+
const sep = chalk.dim('@@ ' + '─'.repeat(w - 4));
|
|
112
|
+
if (hunks.indexOf(hunk) > 0) console.log(sep);
|
|
113
|
+
|
|
114
|
+
for (let k = 0; k < hunk.length; k++) {
|
|
115
|
+
const d = hunk[k];
|
|
116
|
+
const idx = startIdx + k;
|
|
117
|
+
const ln = lineNums[idx];
|
|
118
|
+
const num = String(ln.n ?? ln.o).padStart(4);
|
|
119
|
+
|
|
120
|
+
if (d.type === 'equal') {
|
|
121
|
+
console.log(chalk.dim(`${num} ${d.line}`));
|
|
122
|
+
} else if (d.type === 'del') {
|
|
123
|
+
console.log(chalk.red(`${num} - ${d.line}`));
|
|
124
|
+
removed++;
|
|
125
|
+
} else {
|
|
126
|
+
console.log(chalk.green(`${num} + ${d.line}`));
|
|
127
|
+
added++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (hunks.length === 0) console.log(chalk.dim(' (no changes)'));
|
|
133
|
+
|
|
134
|
+
const summary = chalk.green(`+${added}`) + chalk.dim(' / ') + chalk.red(`-${removed}`);
|
|
135
|
+
console.log(chalk.dim('─'.repeat(w)) + '\n' + ' ' + summary + '\n');
|
|
136
|
+
|
|
137
|
+
return { added, removed, oldContent };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Parse & apply ---
|
|
141
|
+
|
|
142
|
+
export function parseEdits(responseText, loadedFiles = []) {
|
|
143
|
+
const edits = [];
|
|
144
|
+
const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
|
|
145
|
+
let match;
|
|
146
|
+
|
|
147
|
+
while ((match = regex.exec(responseText)) !== null) {
|
|
148
|
+
const tag = match[1].trim();
|
|
149
|
+
const content = match[2];
|
|
150
|
+
|
|
151
|
+
if (tag && (tag.includes('.') || tag.includes('/'))) {
|
|
152
|
+
const exact = loadedFiles.find(f => f === tag || f.endsWith('/' + tag));
|
|
153
|
+
edits.push({ filepath: exact || tag, content });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (tag) {
|
|
158
|
+
const exts = LANG_TO_EXT[tag.toLowerCase()] || [];
|
|
159
|
+
const matched = loadedFiles.find(f => exts.includes(extname(f).toLowerCase()));
|
|
160
|
+
if (matched) { edits.push({ filepath: matched, content }); continue; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (loadedFiles.length === 1) {
|
|
164
|
+
edits.push({ filepath: loadedFiles[0], content });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return edits;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function applyEdit(filepath, content, cwd = process.cwd()) {
|
|
172
|
+
writeFileSync(join(cwd, filepath), content, 'utf-8');
|
|
173
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const FILE_REF_REGEX = /@([\w./\-]+)/g;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finds all @ref patterns in a string and returns the unique file paths.
|
|
8
|
+
*/
|
|
9
|
+
export function parseFileRefs(input) {
|
|
10
|
+
const refs = [];
|
|
11
|
+
let match;
|
|
12
|
+
const regex = new RegExp(FILE_REF_REGEX.source, 'g');
|
|
13
|
+
while ((match = regex.exec(input)) !== null) {
|
|
14
|
+
if (!refs.includes(match[1])) refs.push(match[1]);
|
|
15
|
+
}
|
|
16
|
+
return refs;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads each @ref file from cwd, returns { content, loaded, failed }.
|
|
21
|
+
* `content` is the enriched message with file contents prepended.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveFileRefs(input, cwd = process.cwd()) {
|
|
24
|
+
const refs = parseFileRefs(input);
|
|
25
|
+
if (refs.length === 0) return { content: input, loaded: [], failed: [] };
|
|
26
|
+
|
|
27
|
+
const loaded = [];
|
|
28
|
+
const failed = [];
|
|
29
|
+
const blocks = [];
|
|
30
|
+
|
|
31
|
+
for (const ref of refs) {
|
|
32
|
+
const fullPath = join(cwd, ref);
|
|
33
|
+
if (!existsSync(fullPath)) {
|
|
34
|
+
failed.push(ref);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const text = readFileSync(fullPath, 'utf-8');
|
|
39
|
+
blocks.push(`--- @${ref} ---\n${text}\n---`);
|
|
40
|
+
loaded.push(ref);
|
|
41
|
+
} catch {
|
|
42
|
+
failed.push(ref);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const contextBlock = blocks.length > 0
|
|
47
|
+
? `The user referenced the following file(s) as context:\n\n${blocks.join('\n\n')}\n\n` +
|
|
48
|
+
`If you need to edit a file, output the complete new file content in a fenced code block tagged with the EXACT filename, like:\n` +
|
|
49
|
+
`${loaded.map(f => `\`\`\`${f}\n// full new content\n\`\`\``).join('\n')}\n\n` +
|
|
50
|
+
`IMPORTANT: Use the exact filename shown above as the code block tag, not the language name.\n\n`
|
|
51
|
+
: '';
|
|
52
|
+
|
|
53
|
+
return { content: contextBlock + input, loaded, failed };
|
|
54
|
+
}
|
package/src/input.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { filterFiles } from './ui/picker.js';
|
|
3
|
+
import { MODEL } from './ollama.js';
|
|
4
|
+
|
|
5
|
+
// Strip ANSI escape codes to get the true visible length of a string.
|
|
6
|
+
const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
7
|
+
|
|
8
|
+
const PREFIX = '❯ ';
|
|
9
|
+
const HINT = chalk.dim(' Ask Markov anything...');
|
|
10
|
+
const STATUS_LEFT = chalk.dim('ctrl+q cancel · @ attach files · /cd change dir · /models switch model · /login auth');
|
|
11
|
+
const PICKER_MAX = 6;
|
|
12
|
+
|
|
13
|
+
function border() {
|
|
14
|
+
return chalk.dim('─'.repeat(process.stdout.columns || 80));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function statusBar() {
|
|
18
|
+
const right = chalk.dim(`Markov (${MODEL})`);
|
|
19
|
+
const w = process.stdout.columns || 80;
|
|
20
|
+
const gap = Math.max(1, w - visibleLen(STATUS_LEFT) - visibleLen(right));
|
|
21
|
+
return STATUS_LEFT + ' '.repeat(gap) + right;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Show an interactive raw-mode prompt that supports @file autocomplete.
|
|
26
|
+
* Returns a Promise<string|null> — null means the prompt was cancelled (Ctrl+Q).
|
|
27
|
+
*/
|
|
28
|
+
export function chatPrompt(_promptStr, allFiles) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const stdin = process.stdin;
|
|
31
|
+
const stdout = process.stdout;
|
|
32
|
+
|
|
33
|
+
let buffer = '';
|
|
34
|
+
let pickerFiles = [];
|
|
35
|
+
let pickerIndex = 0;
|
|
36
|
+
let cursorLineOffset = 0; // lines from top of drawn block to where cursor sits
|
|
37
|
+
|
|
38
|
+
const getAtPos = () => {
|
|
39
|
+
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
40
|
+
if (buffer[i] === ' ') return -1;
|
|
41
|
+
if (buffer[i] === '@') return i;
|
|
42
|
+
}
|
|
43
|
+
return -1;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const updatePicker = () => {
|
|
47
|
+
const atPos = getAtPos();
|
|
48
|
+
if (atPos === -1) { pickerFiles = []; pickerIndex = 0; return; }
|
|
49
|
+
pickerFiles = filterFiles(allFiles, buffer.slice(atPos + 1));
|
|
50
|
+
pickerIndex = 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const redraw = () => {
|
|
54
|
+
const inputLine = chalk.cyan(PREFIX) + (buffer || HINT);
|
|
55
|
+
|
|
56
|
+
// Build scrollable picker window of PICKER_MAX items.
|
|
57
|
+
const total = pickerFiles.length;
|
|
58
|
+
const scrollStart = total <= PICKER_MAX ? 0
|
|
59
|
+
: Math.min(Math.max(0, pickerIndex - Math.floor(PICKER_MAX / 2)), total - PICKER_MAX);
|
|
60
|
+
const windowFiles = pickerFiles.slice(scrollStart, scrollStart + PICKER_MAX);
|
|
61
|
+
const pickerRows = windowFiles.map((f, i) => {
|
|
62
|
+
const absIndex = scrollStart + i;
|
|
63
|
+
return absIndex === pickerIndex
|
|
64
|
+
? ' ' + chalk.bgCyan.black(' ' + f + ' ')
|
|
65
|
+
: ' ' + chalk.dim(f);
|
|
66
|
+
});
|
|
67
|
+
if (scrollStart > 0) pickerRows.unshift(chalk.dim(` ↑ ${scrollStart} more`));
|
|
68
|
+
const below = total - scrollStart - PICKER_MAX;
|
|
69
|
+
if (below > 0) pickerRows.push(chalk.dim(` ↓ ${below} more`));
|
|
70
|
+
|
|
71
|
+
const rows = [
|
|
72
|
+
border(),
|
|
73
|
+
inputLine,
|
|
74
|
+
border(),
|
|
75
|
+
...pickerRows,
|
|
76
|
+
statusBar(),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// Move cursor back to the top of the drawn block, then clear down.
|
|
80
|
+
if (cursorLineOffset > 0) stdout.write(`\x1b[${cursorLineOffset}A`);
|
|
81
|
+
stdout.write('\r\x1b[0J');
|
|
82
|
+
|
|
83
|
+
stdout.write(rows.join('\n'));
|
|
84
|
+
|
|
85
|
+
// rows = [border, inputLine, border, ...pickerRows(N), statusBar]
|
|
86
|
+
// inputLine is always at index 1. After writing all rows cursor is on
|
|
87
|
+
// statusBar. Move up (pickerRows.length + 2) to land back on inputLine.
|
|
88
|
+
const upAmount = pickerRows.length + 2;
|
|
89
|
+
stdout.write(`\x1b[${upAmount}A\r`);
|
|
90
|
+
cursorLineOffset = 1; // inputLine is always 1 line from the top
|
|
91
|
+
|
|
92
|
+
const col = visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer) + 1;
|
|
93
|
+
stdout.write(`\x1b[${col}G`);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Clear the panel (called before resolving so output flows cleanly below).
|
|
97
|
+
const clearPanel = () => {
|
|
98
|
+
if (cursorLineOffset > 0) stdout.write(`\x1b[${cursorLineOffset}A`);
|
|
99
|
+
stdout.write('\r\x1b[0J');
|
|
100
|
+
cursorLineOffset = 0;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onSigint = () => {
|
|
104
|
+
clearPanel();
|
|
105
|
+
cleanup();
|
|
106
|
+
stdout.write('\n');
|
|
107
|
+
process.exit(0);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const cleanup = () => {
|
|
111
|
+
stdin.removeListener('data', onData);
|
|
112
|
+
process.removeListener('SIGINT', onSigint);
|
|
113
|
+
stdin.setRawMode(false);
|
|
114
|
+
stdin.pause();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onData = (data) => {
|
|
118
|
+
const key = data.toString();
|
|
119
|
+
|
|
120
|
+
// Ctrl+C → exit
|
|
121
|
+
if (key === '\x03') { onSigint(); return; }
|
|
122
|
+
|
|
123
|
+
// Ctrl+Q → cancel prompt
|
|
124
|
+
if (key === '\x11') {
|
|
125
|
+
clearPanel();
|
|
126
|
+
cleanup();
|
|
127
|
+
stdout.write(chalk.dim('(cancelled)\n'));
|
|
128
|
+
resolve(null);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Ctrl+D → exit
|
|
133
|
+
if (key === '\x04') { onSigint(); return; }
|
|
134
|
+
|
|
135
|
+
// Enter
|
|
136
|
+
if (key === '\r' || key === '\n') {
|
|
137
|
+
if (pickerFiles.length > 0) {
|
|
138
|
+
const atPos = getAtPos();
|
|
139
|
+
if (atPos !== -1) {
|
|
140
|
+
buffer = buffer.slice(0, atPos) + '@' + pickerFiles[pickerIndex] + ' ';
|
|
141
|
+
}
|
|
142
|
+
pickerFiles = [];
|
|
143
|
+
pickerIndex = 0;
|
|
144
|
+
redraw();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
clearPanel();
|
|
148
|
+
cleanup();
|
|
149
|
+
stdout.write('\n');
|
|
150
|
+
resolve(buffer);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Escape → dismiss picker
|
|
155
|
+
if (key === '\x1b' || (key.startsWith('\x1b') && key.length > 1 && !key.startsWith('\x1b['))) {
|
|
156
|
+
pickerFiles = [];
|
|
157
|
+
redraw();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Arrow keys
|
|
162
|
+
if (key === '\x1b[A') {
|
|
163
|
+
if (pickerFiles.length > 0) { pickerIndex = (pickerIndex - 1 + pickerFiles.length) % pickerFiles.length; redraw(); }
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (key === '\x1b[B') {
|
|
167
|
+
if (pickerFiles.length > 0) { pickerIndex = (pickerIndex + 1) % pickerFiles.length; redraw(); }
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (key === '\x1b[C' || key === '\x1b[D') return; // left/right — ignore
|
|
171
|
+
|
|
172
|
+
// Backspace
|
|
173
|
+
if (key === '\x7f' || key === '\b') {
|
|
174
|
+
if (buffer.length > 0) { buffer = buffer.slice(0, -1); updatePicker(); redraw(); }
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Ignore other control chars
|
|
179
|
+
if (key < ' ') return;
|
|
180
|
+
|
|
181
|
+
// Printable
|
|
182
|
+
buffer += key;
|
|
183
|
+
updatePicker();
|
|
184
|
+
redraw();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
stdin.setRawMode(true);
|
|
188
|
+
stdin.resume();
|
|
189
|
+
stdin.setEncoding('utf8');
|
|
190
|
+
stdin.on('data', onData);
|
|
191
|
+
process.on('SIGINT', onSigint);
|
|
192
|
+
redraw();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { printLogo } from './ui/logo.js';
|
|
5
|
+
import { streamChat, MODEL, MODELS, setModel } from './ollama.js';
|
|
6
|
+
import { resolveFileRefs } from './files.js';
|
|
7
|
+
import { parseEdits, applyEdit, renderDiff } from './editor.js';
|
|
8
|
+
import { chatPrompt } from './input.js';
|
|
9
|
+
import { getFiles } from './ui/picker.js';
|
|
10
|
+
import { getToken, login } from './auth.js';
|
|
11
|
+
|
|
12
|
+
/** Arrow-key selector. Returns the chosen string or null if cancelled. */
|
|
13
|
+
function selectFrom(options, label) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
let idx = 0;
|
|
16
|
+
|
|
17
|
+
const draw = () => {
|
|
18
|
+
process.stdout.write('\r\x1b[0J');
|
|
19
|
+
process.stdout.write(chalk.dim(label) + '\n');
|
|
20
|
+
options.forEach((o, i) => {
|
|
21
|
+
process.stdout.write(
|
|
22
|
+
i === idx
|
|
23
|
+
? ' ' + chalk.bgCyan.black(` ${o} `) + '\n'
|
|
24
|
+
: ' ' + chalk.dim(o) + '\n'
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
// Move cursor back up to keep it stable
|
|
28
|
+
process.stdout.write(`\x1b[${options.length + 1}A`);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cleanup = () => {
|
|
32
|
+
process.stdin.removeListener('data', onKey);
|
|
33
|
+
process.stdin.setRawMode(false);
|
|
34
|
+
process.stdin.pause();
|
|
35
|
+
// Clear the drawn lines
|
|
36
|
+
process.stdout.write('\r\x1b[0J');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const onKey = (data) => {
|
|
40
|
+
const key = data.toString();
|
|
41
|
+
if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
|
|
42
|
+
if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
|
|
43
|
+
if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
|
|
44
|
+
if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
process.stdin.setRawMode(true);
|
|
48
|
+
process.stdin.resume();
|
|
49
|
+
process.stdin.setEncoding('utf8');
|
|
50
|
+
process.stdin.on('data', onKey);
|
|
51
|
+
draw();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Prompt y/n in raw mode, returns true for y/Y. */
|
|
56
|
+
function confirm(question) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
process.stdout.write(question);
|
|
59
|
+
process.stdin.setRawMode(true);
|
|
60
|
+
process.stdin.resume();
|
|
61
|
+
process.stdin.setEncoding('utf8');
|
|
62
|
+
const onKey = (key) => {
|
|
63
|
+
process.stdin.removeListener('data', onKey);
|
|
64
|
+
process.stdin.setRawMode(false);
|
|
65
|
+
process.stdin.pause();
|
|
66
|
+
const answer = key.toLowerCase() === 'y';
|
|
67
|
+
process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
|
|
68
|
+
resolve(answer);
|
|
69
|
+
};
|
|
70
|
+
process.stdin.on('data', onKey);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read a visible line of input. */
|
|
75
|
+
function promptLine(label) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
process.stdout.write(label);
|
|
78
|
+
let buf = '';
|
|
79
|
+
const onData = (data) => {
|
|
80
|
+
const key = data.toString();
|
|
81
|
+
if (key === '\r' || key === '\n') {
|
|
82
|
+
process.stdin.removeListener('data', onData);
|
|
83
|
+
process.stdin.setRawMode(false);
|
|
84
|
+
process.stdin.pause();
|
|
85
|
+
process.stdout.write('\n');
|
|
86
|
+
resolve(buf);
|
|
87
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
88
|
+
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
|
|
89
|
+
} else if (key >= ' ') {
|
|
90
|
+
buf += key;
|
|
91
|
+
process.stdout.write(key);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
process.stdin.setRawMode(true);
|
|
95
|
+
process.stdin.resume();
|
|
96
|
+
process.stdin.setEncoding('utf8');
|
|
97
|
+
process.stdin.on('data', onData);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read a line of input without echo (for passwords). */
|
|
102
|
+
function promptSecret(label) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
process.stdout.write(label);
|
|
105
|
+
let buf = '';
|
|
106
|
+
const onData = (data) => {
|
|
107
|
+
const key = data.toString();
|
|
108
|
+
if (key === '\r' || key === '\n') {
|
|
109
|
+
process.stdin.removeListener('data', onData);
|
|
110
|
+
process.stdin.setRawMode(false);
|
|
111
|
+
process.stdin.pause();
|
|
112
|
+
process.stdout.write('\n');
|
|
113
|
+
resolve(buf);
|
|
114
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
115
|
+
if (buf.length > 0) buf = buf.slice(0, -1);
|
|
116
|
+
} else if (key >= ' ') {
|
|
117
|
+
buf += key;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
process.stdin.setRawMode(true);
|
|
121
|
+
process.stdin.resume();
|
|
122
|
+
process.stdin.setEncoding('utf8');
|
|
123
|
+
process.stdin.on('data', onData);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function startInteractive() {
|
|
128
|
+
printLogo();
|
|
129
|
+
|
|
130
|
+
let allFiles = getFiles();
|
|
131
|
+
const chatMessages = [];
|
|
132
|
+
|
|
133
|
+
console.log(chalk.dim(`Chat with Markov (${MODEL}).\n`));
|
|
134
|
+
|
|
135
|
+
if (!getToken()) {
|
|
136
|
+
console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
while (true) {
|
|
140
|
+
const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
|
|
141
|
+
if (raw === null) continue;
|
|
142
|
+
const trimmed = raw.trim();
|
|
143
|
+
|
|
144
|
+
if (!trimmed) continue;
|
|
145
|
+
|
|
146
|
+
// /login — authenticate and save token
|
|
147
|
+
if (trimmed === '/login') {
|
|
148
|
+
const email = await promptLine('Email: ');
|
|
149
|
+
const password = await promptSecret('Password: ');
|
|
150
|
+
try {
|
|
151
|
+
await login(email, password);
|
|
152
|
+
console.log(chalk.green('✓ logged in\n'));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.log(chalk.red(`✗ ${err.message}\n`));
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// /models — pick active model
|
|
160
|
+
if (trimmed === '/models') {
|
|
161
|
+
const chosen = await selectFrom(MODELS, 'Select model:');
|
|
162
|
+
if (chosen) {
|
|
163
|
+
setModel(chosen);
|
|
164
|
+
console.log(chalk.dim(`\n🤖 switched to ${chalk.cyan(chosen)}\n`));
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// /cd [path] — change working directory within this session
|
|
170
|
+
if (trimmed.startsWith('/cd')) {
|
|
171
|
+
const arg = trimmed.slice(3).trim();
|
|
172
|
+
const target = arg
|
|
173
|
+
? resolve(process.cwd(), arg.replace(/^~/, homedir()))
|
|
174
|
+
: homedir();
|
|
175
|
+
try {
|
|
176
|
+
process.chdir(target);
|
|
177
|
+
allFiles = getFiles();
|
|
178
|
+
console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
|
|
179
|
+
} catch {
|
|
180
|
+
console.log(chalk.red(`\nno such directory: ${target}\n`));
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const isEditMode = trimmed.startsWith('/edit ');
|
|
186
|
+
const message = isEditMode ? trimmed.slice(6).trim() : trimmed;
|
|
187
|
+
|
|
188
|
+
const { content, loaded, failed } = resolveFileRefs(message);
|
|
189
|
+
|
|
190
|
+
if (loaded.length > 0) {
|
|
191
|
+
console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
|
|
192
|
+
}
|
|
193
|
+
if (failed.length > 0) {
|
|
194
|
+
console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
chatMessages.push({ role: 'user', content });
|
|
198
|
+
|
|
199
|
+
// Animated waiting dots — cleared on first token
|
|
200
|
+
const DOTS = ['.', '..', '...'];
|
|
201
|
+
let dotIdx = 0;
|
|
202
|
+
let firstToken = true;
|
|
203
|
+
process.stdout.write(chalk.dim('\nMarkov › '));
|
|
204
|
+
const spinner = setInterval(() => {
|
|
205
|
+
process.stdout.write('\r' + chalk.dim('Markov › ' + DOTS[dotIdx % DOTS.length] + ' '));
|
|
206
|
+
dotIdx++;
|
|
207
|
+
}, 400);
|
|
208
|
+
|
|
209
|
+
const abortController = new AbortController();
|
|
210
|
+
try {
|
|
211
|
+
|
|
212
|
+
// Listen for Ctrl+Q on raw stdin to cancel the stream
|
|
213
|
+
const onCancel = (data) => {
|
|
214
|
+
if (data.toString() === '\x11') abortController.abort();
|
|
215
|
+
};
|
|
216
|
+
process.stdin.setRawMode(true);
|
|
217
|
+
process.stdin.resume();
|
|
218
|
+
process.stdin.setEncoding('utf8');
|
|
219
|
+
process.stdin.on('data', onCancel);
|
|
220
|
+
|
|
221
|
+
const reply = await streamChat(chatMessages, (token) => {
|
|
222
|
+
if (firstToken) {
|
|
223
|
+
clearInterval(spinner);
|
|
224
|
+
firstToken = false;
|
|
225
|
+
process.stdout.write('\r\x1b[0J' + chalk.dim('Markov ›\n\n'));
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write(token);
|
|
228
|
+
}, undefined, abortController.signal);
|
|
229
|
+
|
|
230
|
+
process.stdin.removeListener('data', onCancel);
|
|
231
|
+
process.stdin.setRawMode(false);
|
|
232
|
+
process.stdin.pause();
|
|
233
|
+
clearInterval(spinner);
|
|
234
|
+
|
|
235
|
+
if (abortController.signal.aborted) {
|
|
236
|
+
console.log(chalk.dim('\n(cancelled)\n'));
|
|
237
|
+
} else {
|
|
238
|
+
console.log('\n');
|
|
239
|
+
chatMessages.push({ role: 'assistant', content: reply });
|
|
240
|
+
|
|
241
|
+
// Detect file edits in the response (only in /edit mode)
|
|
242
|
+
const edits = isEditMode ? parseEdits(reply, loaded) : [];
|
|
243
|
+
const appliedEdits = [];
|
|
244
|
+
for (const { filepath, content } of edits) {
|
|
245
|
+
renderDiff(filepath, content);
|
|
246
|
+
const confirmed = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(filepath)}? [y/N] `));
|
|
247
|
+
if (confirmed) {
|
|
248
|
+
try {
|
|
249
|
+
applyEdit(filepath, content);
|
|
250
|
+
console.log(chalk.green(`✓ saved ${filepath}\n`));
|
|
251
|
+
appliedEdits.push({ filepath, content });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
console.log(chalk.dim('skipped\n'));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Refresh chat context with updated file contents so the model
|
|
261
|
+
// doesn't reference stale content from earlier in the conversation.
|
|
262
|
+
if (appliedEdits.length > 0) {
|
|
263
|
+
const blocks = appliedEdits.map(({ filepath, content }) => `--- @${filepath} ---\n${content}\n---`);
|
|
264
|
+
chatMessages.push({
|
|
265
|
+
role: 'user',
|
|
266
|
+
content: `The following file(s) were just saved with the applied changes:\n\n${blocks.join('\n\n')}`,
|
|
267
|
+
});
|
|
268
|
+
chatMessages.push({ role: 'assistant', content: 'Got it, I have the updated file contents.' });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
clearInterval(spinner);
|
|
273
|
+
if (!abortController.signal.aborted) {
|
|
274
|
+
console.log(chalk.red(`\n${err.message}\n`));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/ollama.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { API_URL, getToken } from './auth.js';
|
|
2
|
+
|
|
3
|
+
const getHeaders = () => {
|
|
4
|
+
const token = getToken();
|
|
5
|
+
return { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) };
|
|
6
|
+
};
|
|
7
|
+
export const MODELS = ['gemma3:4b', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:7b', 'qwen3:14b'];
|
|
8
|
+
export let MODEL = 'qwen3:14b';
|
|
9
|
+
export function setModel(m) { MODEL = m; }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stream a chat response from Ollama.
|
|
13
|
+
* Calls onToken(string) for each token, returns the full response string.
|
|
14
|
+
* Pass an AbortSignal to allow cancellation mid-stream.
|
|
15
|
+
*/
|
|
16
|
+
export async function streamChat(messages, onToken, _model = MODEL, signal = null) {
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await fetch(`${API_URL}/ai/chat/stream`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: getHeaders(),
|
|
22
|
+
body: JSON.stringify({ messages, temperature: 0.2 }),
|
|
23
|
+
signal,
|
|
24
|
+
});
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (signal?.aborted) return '';
|
|
27
|
+
throw new Error(`Could not connect to ${API_URL}/ai/chat/stream — ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
const body = await response.text().catch(() => '');
|
|
32
|
+
throw new Error(`API error ${response.status} ${response.statusText}${body ? ': ' + body : ''}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const reader = response.body.getReader();
|
|
36
|
+
const decoder = new TextDecoder();
|
|
37
|
+
let fullText = '';
|
|
38
|
+
let buffer = '';
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
while (true) {
|
|
42
|
+
if (signal?.aborted) { reader.cancel(); break; }
|
|
43
|
+
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) break;
|
|
46
|
+
|
|
47
|
+
buffer += decoder.decode(value, { stream: true });
|
|
48
|
+
const lines = buffer.split('\n');
|
|
49
|
+
buffer = lines.pop() || '';
|
|
50
|
+
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
if (!line.startsWith('data: ')) continue;
|
|
53
|
+
const data = line.slice(6);
|
|
54
|
+
if (data === '[DONE]') return fullText;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(data);
|
|
57
|
+
if (parsed.content) {
|
|
58
|
+
onToken(parsed.content);
|
|
59
|
+
fullText += parsed.content;
|
|
60
|
+
}
|
|
61
|
+
} catch { /* skip malformed */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!signal?.aborted) throw err;
|
|
66
|
+
} finally {
|
|
67
|
+
reader.releaseLock();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return fullText;
|
|
71
|
+
}
|
package/src/ui/logo.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import gradient from 'gradient-string';
|
|
2
|
+
|
|
3
|
+
const ASCII_ART = `
|
|
4
|
+
███╗ ███╗ █████╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗
|
|
5
|
+
████╗ ████║██╔══██╗██╔══██╗██║ ██╔╝██╔═══██╗██║ ██║
|
|
6
|
+
██╔████╔██║███████║██████╔╝█████╔╝ ██ ██║██║ ██║
|
|
7
|
+
██║╚██╔╝██║██╔══██║██╔══██╗██╔═██╗ ██║ ██║╚██╗ ██╔╝
|
|
8
|
+
██║ ╚═╝ ██║██║ ██║██║ ██║██║ ██╗╚██████╔╝ ╚████╔╝
|
|
9
|
+
╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═══╝
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
const SMALL_MARKOV = `
|
|
13
|
+
█ █ ██ █ ██ █ █ ██ █ █
|
|
14
|
+
██ ██ █ ██ ██ ███ █ █ ███
|
|
15
|
+
█ █ █ █ █ █ █ ██ ██ █ █
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
|
|
19
|
+
|
|
20
|
+
export function printLogo() {
|
|
21
|
+
console.log(markovGradient.multiline(ASCII_ART));
|
|
22
|
+
console.log(' A friendly CLI tool\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function printSmallMarkov() {
|
|
26
|
+
console.log(markovGradient.multiline(SMALL_MARKOV));
|
|
27
|
+
}
|
package/src/ui/picker.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readdirSync } from 'fs';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
|
|
4
|
+
const IGNORE = new Set([
|
|
5
|
+
'.git', 'node_modules', '.next', 'dist', 'build', '.cache',
|
|
6
|
+
'coverage', '__pycache__', '.venv', 'venv', '.DS_Store',
|
|
7
|
+
]);
|
|
8
|
+
const MAX_FILES = 300;
|
|
9
|
+
|
|
10
|
+
export function getFiles(dir = process.cwd(), base = dir, depth = 0, acc = []) {
|
|
11
|
+
if (depth > 5 || acc.length >= MAX_FILES) return acc;
|
|
12
|
+
let entries;
|
|
13
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (IGNORE.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
16
|
+
const full = join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
getFiles(full, base, depth + 1, acc);
|
|
19
|
+
} else {
|
|
20
|
+
acc.push(relative(base, full));
|
|
21
|
+
}
|
|
22
|
+
if (acc.length >= MAX_FILES) break;
|
|
23
|
+
}
|
|
24
|
+
return acc;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function filterFiles(files, query) {
|
|
28
|
+
if (!query) return files;
|
|
29
|
+
const q = query.toLowerCase();
|
|
30
|
+
return files.filter(f => f.toLowerCase().includes(q));
|
|
31
|
+
}
|