@vierratale/ai 0.1.0-beta.7 → 0.1.0-beta.9
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/README.md +120 -16
- package/bin/vierrataleai.js +74 -1
- package/package.json +7 -1
- package/src/catalog.js +78 -49
- package/src/cli.js +886 -91
- package/src/cmd/agent.js +1695 -0
- package/src/cmd/executor.js +257 -0
- package/src/cmd/tools.js +131 -0
- package/src/config.js +24 -10
- package/src/index.js +1 -0
- package/src/installer.js +149 -6
- package/src/prompts/system.json +2 -2
- package/src/prompts/system.md +42 -0
- package/src/providers/anthropic.js +65 -34
- package/src/providers/base.js +10 -0
- package/src/providers/cortex.js +236 -36
- package/src/providers/gemini.js +62 -31
- package/src/providers/openai.js +64 -33
- package/src/session.js +195 -15
- package/src/ui/branding.js +1 -1
- package/src/ui/chatbox.js +271 -0
- package/src/ui/terminal.js +49 -16
- package/src/utils/filewriter.js +97 -0
- package/src/utils/intents.js +73 -1
- package/src/utils/logger.js +67 -0
- package/src/utils/math.js +142 -0
- package/build.sh +0 -17
package/src/providers/openai.js
CHANGED
|
@@ -57,7 +57,7 @@ export class OpenAIProvider extends BaseProvider {
|
|
|
57
57
|
|
|
58
58
|
async *stream(messages, options = {}) {
|
|
59
59
|
const key = Config.get('openaiApiKey');
|
|
60
|
-
if (!key) throw new Error('Cloud API key not configured');
|
|
60
|
+
if (!key) throw new Error('[ERR-0004] Cloud API key not configured. Add it to the config and try again.');
|
|
61
61
|
|
|
62
62
|
const model = this._resolveModel(options);
|
|
63
63
|
const systemPrompt = options.systemPrompt || '';
|
|
@@ -70,47 +70,78 @@ export class OpenAIProvider extends BaseProvider {
|
|
|
70
70
|
apiMessages.push({ role: msg.role, content: msg.content });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const connectTimer = setTimeout(() => controller.abort(), 240000);
|
|
75
|
+
|
|
76
|
+
let resp;
|
|
77
|
+
try {
|
|
78
|
+
resp = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
Authorization: `Bearer ${key}`,
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
model,
|
|
86
|
+
messages: apiMessages,
|
|
87
|
+
stream: true,
|
|
88
|
+
temperature: options.temperature || Config.get('temperature'),
|
|
89
|
+
max_tokens: options.maxTokens || Config.get('maxTokens'),
|
|
90
|
+
}),
|
|
91
|
+
signal: controller.signal,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.name === 'AbortError') {
|
|
95
|
+
throw new Error('[ERR-0001] Could not reach the API (no response within 60s). Check your network connection.');
|
|
96
|
+
}
|
|
97
|
+
throw new Error('[ERR-0001] Could not reach the API (no response). Check your network connection.');
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(connectTimer);
|
|
100
|
+
}
|
|
87
101
|
|
|
88
102
|
if (!resp.ok) {
|
|
89
|
-
throw new Error('
|
|
103
|
+
throw new Error('[ERR-0003] The cloud provider returned HTTP ' + resp.status + '. Check your API key in config.');
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
const reader = resp.body.getReader();
|
|
93
107
|
const decoder = new TextDecoder();
|
|
94
108
|
let buffer = '';
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
for (const line of lines) {
|
|
105
|
-
if (!line.startsWith('data: ')) continue;
|
|
106
|
-
const data = line.slice(6);
|
|
107
|
-
if (data === '[DONE]') return;
|
|
109
|
+
let idle = null;
|
|
110
|
+
const settle = () => {
|
|
111
|
+
if (idle) clearTimeout(idle);
|
|
112
|
+
idle = setTimeout(() => controller.abort(), 240000);
|
|
113
|
+
};
|
|
114
|
+
settle();
|
|
115
|
+
try {
|
|
116
|
+
while (true) {
|
|
117
|
+
let value, done;
|
|
108
118
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
119
|
+
({ done, value } = await reader.read());
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.name === 'AbortError') {
|
|
122
|
+
throw new Error('[ERR-0001] Lost the connection to the cloud API (no data for 60s).');
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
if (done) break;
|
|
127
|
+
settle();
|
|
128
|
+
buffer += decoder.decode(value, { stream: true });
|
|
129
|
+
const lines = buffer.split('\n');
|
|
130
|
+
buffer = lines.pop() || '';
|
|
131
|
+
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
if (!line.startsWith('data: ')) continue;
|
|
134
|
+
const data = line.slice(6);
|
|
135
|
+
if (data === '[DONE]') return;
|
|
136
|
+
try {
|
|
137
|
+
const json = JSON.parse(data);
|
|
138
|
+
const content = json.choices?.[0]?.delta?.content;
|
|
139
|
+
if (content) yield content;
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
113
142
|
}
|
|
143
|
+
} finally {
|
|
144
|
+
if (idle) clearTimeout(idle);
|
|
114
145
|
}
|
|
115
146
|
}
|
|
116
147
|
}
|
package/src/session.js
CHANGED
|
@@ -1,16 +1,141 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { Config } from './config.js';
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const ROOT = join(Config.getConfigDir(), 'sessions');
|
|
6
|
+
const ACTIVE_FILE = join(ROOT, '.active');
|
|
7
|
+
const MIGRATED_FLAG = join(ROOT, '.migrated');
|
|
6
8
|
const MAX_MESSAGES = 20;
|
|
7
9
|
|
|
10
|
+
const sanitize = (name) =>
|
|
11
|
+
String(name || '').replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'default';
|
|
12
|
+
|
|
13
|
+
const pathFor = (name) => join(ROOT, `${sanitize(name)}.json`);
|
|
14
|
+
const backupPathFor = (name) => join(ROOT, `${sanitize(name)}.bak.json`);
|
|
15
|
+
|
|
16
|
+
const autoName = () => {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
19
|
+
return `session-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${p(d.getMilliseconds())}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function ensureDir() {
|
|
23
|
+
if (!existsSync(ROOT)) mkdirSync(ROOT, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function migrateLegacy() {
|
|
27
|
+
// One-time move of the old single history.json into a session named "default".
|
|
28
|
+
// The .migrated flag guarantees the legacy file can never resurrect a deleted
|
|
29
|
+
// "default" session on later runs.
|
|
30
|
+
try {
|
|
31
|
+
const legacy = join(Config.getConfigDir(), 'history.json');
|
|
32
|
+
if (existsSync(legacy) && !existsSync(MIGRATED_FLAG)) {
|
|
33
|
+
if (!existsSync(pathFor('default'))) {
|
|
34
|
+
const data = JSON.parse(readFileSync(legacy, 'utf-8') || '[]');
|
|
35
|
+
if (Array.isArray(data) && data.length) {
|
|
36
|
+
writeFileSync(pathFor('default'), JSON.stringify(data.slice(-MAX_MESSAGES), null, 2));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
writeFileSync(MIGRATED_FLAG, new Date().toISOString());
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
8
44
|
export const Session = {
|
|
45
|
+
activeName() {
|
|
46
|
+
ensureDir();
|
|
47
|
+
migrateLegacy();
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(ACTIVE_FILE)) {
|
|
50
|
+
const name = readFileSync(ACTIVE_FILE, 'utf-8').trim();
|
|
51
|
+
if (name && existsSync(pathFor(name))) return name;
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
if (!existsSync(pathFor('default'))) writeFileSync(pathFor('default'), '[]');
|
|
55
|
+
writeFileSync(ACTIVE_FILE, 'default');
|
|
56
|
+
return 'default';
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
list() {
|
|
60
|
+
ensureDir();
|
|
61
|
+
migrateLegacy();
|
|
62
|
+
const out = [];
|
|
63
|
+
const active = this.activeName();
|
|
64
|
+
for (const f of readdirSync(ROOT)) {
|
|
65
|
+
if (!f.endsWith('.json')) continue;
|
|
66
|
+
const name = f.slice(0, -5);
|
|
67
|
+
try {
|
|
68
|
+
const msgs = JSON.parse(readFileSync(join(ROOT, f), 'utf-8') || '[]');
|
|
69
|
+
const first = msgs.find((m) => m.role === 'user');
|
|
70
|
+
out.push({
|
|
71
|
+
name,
|
|
72
|
+
count: msgs.length,
|
|
73
|
+
preview: first ? String(first.content).slice(0, 60) : '(empty)',
|
|
74
|
+
updated: statSync(join(ROOT, f)).mtimeMs,
|
|
75
|
+
active: name === active,
|
|
76
|
+
});
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
out.sort((a, b) => b.updated - a.updated);
|
|
80
|
+
return out;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
create(name) {
|
|
84
|
+
ensureDir();
|
|
85
|
+
const safe = sanitize(name || autoName());
|
|
86
|
+
writeFileSync(pathFor(safe), '[]');
|
|
87
|
+
writeFileSync(ACTIVE_FILE, safe);
|
|
88
|
+
return safe;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
open(name) {
|
|
92
|
+
const sessions = this.list();
|
|
93
|
+
let target = name;
|
|
94
|
+
if (/^#\d+$/.test(name)) {
|
|
95
|
+
const idx = parseInt(name.slice(1), 10);
|
|
96
|
+
const s = sessions[idx - 1];
|
|
97
|
+
if (!s) return null;
|
|
98
|
+
target = s.name;
|
|
99
|
+
}
|
|
100
|
+
const safe = sanitize(target);
|
|
101
|
+
if (!existsSync(pathFor(safe))) return null;
|
|
102
|
+
writeFileSync(ACTIVE_FILE, safe);
|
|
103
|
+
return safe;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
remove(name) {
|
|
107
|
+
const sessions = this.list();
|
|
108
|
+
let target = name;
|
|
109
|
+
if (/^#\d+$/.test(name)) {
|
|
110
|
+
const idx = parseInt(name.slice(1), 10);
|
|
111
|
+
const s = sessions[idx - 1];
|
|
112
|
+
if (!s) return null;
|
|
113
|
+
target = s.name;
|
|
114
|
+
}
|
|
115
|
+
const safe = sanitize(target);
|
|
116
|
+
const file = pathFor(safe);
|
|
117
|
+
if (!existsSync(file)) return null;
|
|
118
|
+
if (safe === this.activeName()) {
|
|
119
|
+
const others = sessions.filter((s) => s.name !== safe);
|
|
120
|
+
if (others.length) {
|
|
121
|
+
writeFileSync(ACTIVE_FILE, others[0].name);
|
|
122
|
+
} else {
|
|
123
|
+
writeFileSync(ACTIVE_FILE, this.create(null));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
try { unlinkSync(file); } catch {}
|
|
127
|
+
return safe;
|
|
128
|
+
},
|
|
129
|
+
|
|
9
130
|
load() {
|
|
131
|
+
return this.loadActive();
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
loadActive() {
|
|
10
135
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const data = JSON.parse(
|
|
136
|
+
const path = pathFor(this.activeName());
|
|
137
|
+
if (existsSync(path)) {
|
|
138
|
+
const data = JSON.parse(readFileSync(path, 'utf-8') || '[]');
|
|
14
139
|
if (Array.isArray(data)) return data.slice(-MAX_MESSAGES);
|
|
15
140
|
}
|
|
16
141
|
} catch {}
|
|
@@ -19,23 +144,78 @@ export const Session = {
|
|
|
19
144
|
|
|
20
145
|
save(messages) {
|
|
21
146
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
writeFileSync(SESSION_FILE, JSON.stringify(messages.slice(-MAX_MESSAGES), null, 2));
|
|
147
|
+
ensureDir();
|
|
148
|
+
writeFileSync(pathFor(this.activeName()), JSON.stringify(messages.slice(-MAX_MESSAGES), null, 2));
|
|
25
149
|
} catch {}
|
|
26
150
|
},
|
|
27
151
|
|
|
152
|
+
// Snapshot the active session to a timestamped .bak.json before it is
|
|
153
|
+
// replaced or cleared, so a fresh-start or /new never loses the old talk.
|
|
154
|
+
autoBackup() {
|
|
155
|
+
try {
|
|
156
|
+
ensureDir();
|
|
157
|
+
const name = this.activeName();
|
|
158
|
+
const src = pathFor(name);
|
|
159
|
+
if (!existsSync(src)) return null;
|
|
160
|
+
const msgs = JSON.parse(readFileSync(src, 'utf-8') || '[]');
|
|
161
|
+
if (!Array.isArray(msgs) || msgs.length === 0) return null;
|
|
162
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
163
|
+
const dest = backupPathFor(`${name}.${stamp}`);
|
|
164
|
+
writeFileSync(dest, JSON.stringify(msgs, null, 2));
|
|
165
|
+
return `${name}.${stamp}.bak.json`;
|
|
166
|
+
} catch { return null; }
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
backup() {
|
|
170
|
+
return this.autoBackup();
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// List .bak.json recovery copies for the sessions UI.
|
|
174
|
+
listBackups() {
|
|
175
|
+
ensureDir();
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const f of readdirSync(ROOT)) {
|
|
178
|
+
if (!f.endsWith('.bak.json')) continue;
|
|
179
|
+
const name = f.slice(0, -'.bak.json'.length);
|
|
180
|
+
try {
|
|
181
|
+
out.push({ name, file: f, updated: statSync(join(ROOT, f)).mtimeMs });
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
out.sort((a, b) => b.updated - a.updated);
|
|
185
|
+
return out;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// Restore a backup: copy the .bak.json content back into a normal session file
|
|
189
|
+
// (creating <name>-restored if the original still exists) and switch to it.
|
|
190
|
+
restoreBackup(file) {
|
|
191
|
+
try {
|
|
192
|
+
ensureDir();
|
|
193
|
+
let fname = String(file || '').trim();
|
|
194
|
+
if (!fname.endsWith('.bak.json')) {
|
|
195
|
+
if (!fname.endsWith('.json')) fname += '.bak.json';
|
|
196
|
+
}
|
|
197
|
+
const safe = sanitize(fname.replace(/\.bak\.json$/, ''));
|
|
198
|
+
const src = backupPathFor(safe);
|
|
199
|
+
if (!existsSync(src)) return null;
|
|
200
|
+
const msgs = JSON.parse(readFileSync(src, 'utf-8') || '[]');
|
|
201
|
+
if (!Array.isArray(msgs)) return null;
|
|
202
|
+
const base = sanitize(safe.replace(/\.\d{4}-\d{2}-\d{2}T[\d-]+$/, ''));
|
|
203
|
+
let target = base;
|
|
204
|
+
if (existsSync(pathFor(target))) target = `${base}-restored`;
|
|
205
|
+
writeFileSync(pathFor(target), JSON.stringify(msgs.slice(-MAX_MESSAGES), null, 2));
|
|
206
|
+
writeFileSync(ACTIVE_FILE, target);
|
|
207
|
+
return target;
|
|
208
|
+
} catch { return null; }
|
|
209
|
+
},
|
|
210
|
+
|
|
28
211
|
clear() {
|
|
29
212
|
try {
|
|
30
|
-
|
|
213
|
+
ensureDir();
|
|
214
|
+
writeFileSync(pathFor(this.activeName()), '[]');
|
|
31
215
|
} catch {}
|
|
32
216
|
},
|
|
33
217
|
|
|
34
218
|
hasHistory() {
|
|
35
|
-
|
|
36
|
-
return existsSync(SESSION_FILE) && (JSON.parse(readFileSync(SESSION_FILE, 'utf-8') || '[]').length > 0);
|
|
37
|
-
} catch {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
219
|
+
return this.loadActive().length > 0;
|
|
40
220
|
},
|
|
41
|
-
};
|
|
221
|
+
};
|
package/src/ui/branding.js
CHANGED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { Branding } from './branding.js';
|
|
2
|
+
|
|
3
|
+
const C = Branding.colors;
|
|
4
|
+
|
|
5
|
+
const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
6
|
+
|
|
7
|
+
function padRight(text, width) {
|
|
8
|
+
const t = String(text ?? '');
|
|
9
|
+
return t + ' '.repeat(Math.max(0, width - visible(t)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function wrapLines(text, width) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const src = String(text ?? '');
|
|
15
|
+
for (const raw of src.split('\n')) {
|
|
16
|
+
if (!raw.trim()) {
|
|
17
|
+
out.push('');
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const words = raw.split(/\s+/).filter(Boolean);
|
|
21
|
+
let cur = '';
|
|
22
|
+
for (const word of words) {
|
|
23
|
+
if (word.length >= width) {
|
|
24
|
+
if (cur) {
|
|
25
|
+
out.push(cur);
|
|
26
|
+
cur = '';
|
|
27
|
+
}
|
|
28
|
+
for (let i = 0; i < word.length; i += width) out.push(word.slice(i, i + width));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const next = cur ? `${cur} ${word}` : word;
|
|
32
|
+
if (visible(next) > width) {
|
|
33
|
+
if (cur) out.push(cur);
|
|
34
|
+
cur = word;
|
|
35
|
+
} else {
|
|
36
|
+
cur = next;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (cur) out.push(cur);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hardWrap(text, width) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const src = String(text ?? '').replace(/\t/g, ' ');
|
|
47
|
+
for (const raw of src.split('\n')) {
|
|
48
|
+
if (visible(raw) <= width) {
|
|
49
|
+
out.push(raw);
|
|
50
|
+
} else {
|
|
51
|
+
for (let i = 0; i < raw.length; i += width) out.push(raw.slice(i, i + width));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Column geometry: true-full width, then a centered column like opencode.
|
|
58
|
+
function columnWidth(termWidth) {
|
|
59
|
+
const w = termWidth && termWidth > 44 ? termWidth : 96;
|
|
60
|
+
return Math.max(40, Math.min(100, w - 8));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function centerPad(col, termWidth) {
|
|
64
|
+
const w = termWidth && termWidth > 44 ? termWidth : 96;
|
|
65
|
+
const pad = Math.max(0, Math.floor((w - col) / 2));
|
|
66
|
+
return ' '.repeat(pad);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function highlight(text) {
|
|
70
|
+
return String(text)
|
|
71
|
+
.replace(/\*\*([^*]+)\*\*/g, `${C.bold}$1${C.reset}`)
|
|
72
|
+
.replace(/`([^`]+)`/g, `${C.accent}$1${C.reset}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function splitBlocks(text) {
|
|
76
|
+
const blocks = [];
|
|
77
|
+
const re = /```([\w./+-]*)[^\n]*\n?([\s\S]*?)(?:```|$)/g;
|
|
78
|
+
let last = 0;
|
|
79
|
+
let m;
|
|
80
|
+
while ((m = re.exec(String(text)))) {
|
|
81
|
+
if (m.index > last) blocks.push({ code: false, text: text.slice(last, m.index) });
|
|
82
|
+
blocks.push({ code: true, lang: m[1] || 'code', content: m[2] });
|
|
83
|
+
last = m.index + m[0].length;
|
|
84
|
+
}
|
|
85
|
+
if (last < text.length) blocks.push({ code: false, text: text.slice(last) });
|
|
86
|
+
return blocks;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class FrameThrottle {
|
|
90
|
+
constructor(render, min = 40) {
|
|
91
|
+
this.render = render;
|
|
92
|
+
this.min = min;
|
|
93
|
+
this.last = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
schedule() {
|
|
97
|
+
const now = performance.now();
|
|
98
|
+
if (now - this.last >= this.min) {
|
|
99
|
+
this.last = now;
|
|
100
|
+
this.render();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
flush() {
|
|
105
|
+
this.render();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class ChatUI {
|
|
110
|
+
constructor({ model = '', engine = '' } = {}) {
|
|
111
|
+
this.model = model;
|
|
112
|
+
this.engine = engine;
|
|
113
|
+
this.status = '';
|
|
114
|
+
this.notices = [];
|
|
115
|
+
this.streaming = null;
|
|
116
|
+
this.thinking = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
width() {
|
|
120
|
+
const w = process.stdout.columns;
|
|
121
|
+
return w && w > 44 ? w : 96;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setModel(model, engine) {
|
|
125
|
+
this.model = model;
|
|
126
|
+
this.engine = engine;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setStatus(text) {
|
|
130
|
+
this.status = String(text ?? '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setThinking(flag) {
|
|
134
|
+
this.thinking = !!flag;
|
|
135
|
+
this.streaming = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setStreaming(text) {
|
|
139
|
+
this.streaming = String(text ?? '');
|
|
140
|
+
this.thinking = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
notify(text) {
|
|
144
|
+
if (text) this.notices.push(String(text));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
clearNotices() {
|
|
148
|
+
this.notices = [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_codeBox(lang, content, w) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const c = C.success;
|
|
154
|
+
// The box lives inside the centered column and carries its own 4-space
|
|
155
|
+
// indent, so draw it at (w - 4) to stay inside the column.
|
|
156
|
+
const box = Math.max(8, w - 4);
|
|
157
|
+
const inner = Math.max(1, box - 8);
|
|
158
|
+
const head = ` ${c}╭─${C.reset} ${C.bold}${c}${lang || 'code'}${C.reset}${c} `;
|
|
159
|
+
const fill = Math.max(0, box - visible(head) - 1);
|
|
160
|
+
out.push(`${head}${'─'.repeat(fill)}╮${C.reset}`);
|
|
161
|
+
const lines = hardWrap(content, inner);
|
|
162
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
163
|
+
if (!lines.length) lines.push('');
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
out.push(` ${c}│${C.reset} ${padRight(line, inner)} ${c}│${C.reset}`);
|
|
166
|
+
}
|
|
167
|
+
out.push(` ${c}╰${'─'.repeat(Math.max(0, box - 6))}╯${C.reset}`);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_contentRows(content, w) {
|
|
172
|
+
const rows = [];
|
|
173
|
+
const width = Math.max(20, w - 4);
|
|
174
|
+
const blocks = splitBlocks(content);
|
|
175
|
+
for (const b of blocks) {
|
|
176
|
+
if (b.code) {
|
|
177
|
+
rows.push(...this._codeBox(b.lang, b.content, w));
|
|
178
|
+
} else {
|
|
179
|
+
const lines = wrapLines(b.text, width);
|
|
180
|
+
if (lines.length === 0) lines.push('');
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
rows.push(line.trim() === '' ? '' : ` ${highlight(line)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return rows;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_centered(row) {
|
|
190
|
+
return `${centerPad(columnWidth(process.stdout.columns), process.stdout.columns)}${row}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Banner drawn as part of the full clear+draw cycle so it can never be
|
|
194
|
+
// wiped by a later render (the old showBanner() was erased instantly).
|
|
195
|
+
_bannerRows(col) {
|
|
196
|
+
const out = [];
|
|
197
|
+
// Center the art as a BLOCK: pad every line to the widest one, then use a
|
|
198
|
+
// single shared left indent so the llama's rows stay glued together.
|
|
199
|
+
const art = Branding.BANNER.split('\n').map((l) => l.trimEnd());
|
|
200
|
+
const artW = Math.max(...art.map((l) => visible(l)));
|
|
201
|
+
const padL = Math.max(0, Math.floor((col - artW) / 2));
|
|
202
|
+
for (const line of art) {
|
|
203
|
+
out.push(`${' '.repeat(padL)}${C.primary}${line}${C.reset}`);
|
|
204
|
+
}
|
|
205
|
+
const center = (s) => {
|
|
206
|
+
const p = Math.max(0, Math.floor((col - visible(s)) / 2));
|
|
207
|
+
return `${' '.repeat(p)}${s}`;
|
|
208
|
+
};
|
|
209
|
+
out.push(center(`${C.bold}${C.accent}▸ ${Branding.APP_NAME} · ${this.model} · ${this.engine}${C.reset}`));
|
|
210
|
+
out.push('');
|
|
211
|
+
const box = Math.max(24, col - 6);
|
|
212
|
+
const padR = (s) => s + ' '.repeat(Math.max(1, box - visible(s)));
|
|
213
|
+
// Border is ` ┌` + (box+2) dashes + `┐` = box+6 wide, matching every row.
|
|
214
|
+
const borderLine = ` ${C.primary}┌${'─'.repeat(box + 2)}┐${C.reset}`;
|
|
215
|
+
out.push(center(borderLine));
|
|
216
|
+
out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.bold + C.primary + 'Model' + C.reset)} ${C.primary}│${C.reset}`));
|
|
217
|
+
out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.accent + this.model + C.reset)} ${C.primary}│${C.reset}`));
|
|
218
|
+
out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.bold + C.primary + 'Engine' + C.reset)} ${C.primary}│${C.reset}`));
|
|
219
|
+
out.push(center(` ${C.primary}│${C.reset} ${padR(' ' + C.accent + this.engine + C.reset)} ${C.primary}│${C.reset}`));
|
|
220
|
+
out.push(center(` ${C.primary}└${'─'.repeat(box + 2)}┘${C.reset}`));
|
|
221
|
+
out.push('');
|
|
222
|
+
out.push(center(`${C.dim}Type /help for commands · /quit to exit${C.reset}`));
|
|
223
|
+
out.push('');
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
render(messages) {
|
|
228
|
+
const w = this.width();
|
|
229
|
+
const col = columnWidth(w);
|
|
230
|
+
const out = [];
|
|
231
|
+
out.push(`\x1b[2J\x1b[H`);
|
|
232
|
+
// Banner (opencode style header) at the top of the centered column.
|
|
233
|
+
out.push(...this._bannerRows(col));
|
|
234
|
+
if (this.status) out.push(this._centered(` ${C.dim}${this.status}${C.reset}`));
|
|
235
|
+
out.push('');
|
|
236
|
+
|
|
237
|
+
for (const m of messages || []) {
|
|
238
|
+
if (!m || m.hidden || !m.content) continue;
|
|
239
|
+
const title = m.role === 'user' ? 'You' : Branding.AI_PROMPT;
|
|
240
|
+
const color = m.role === 'user' ? C.accent : C.primary;
|
|
241
|
+
out.push(this._centered(` ${C.bold}${color}${title}${C.reset}`));
|
|
242
|
+
for (const row of this._contentRows(m.content, col)) {
|
|
243
|
+
out.push(this._centered(row));
|
|
244
|
+
}
|
|
245
|
+
out.push('');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (this.thinking) {
|
|
249
|
+
out.push(this._centered(` ${C.bold}${C.primary}${Branding.AI_PROMPT}${C.reset}`));
|
|
250
|
+
out.push(this._centered(` ${C.dim}···${C.reset}`));
|
|
251
|
+
out.push('');
|
|
252
|
+
} else if (this.streaming !== null) {
|
|
253
|
+
out.push(this._centered(` ${C.bold}${C.primary}${Branding.AI_PROMPT}${C.reset}`));
|
|
254
|
+
const rows = this._contentRows(this.streaming, col);
|
|
255
|
+
for (const r of rows.length ? rows : [' ']) out.push(this._centered(r));
|
|
256
|
+
out.push('');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const n of this.notices) {
|
|
260
|
+
for (const line of wrapLines(n, col - 4)) out.push(this._centered(` ${C.dim}${line}${C.reset}`));
|
|
261
|
+
}
|
|
262
|
+
if (this.notices.length) out.push('');
|
|
263
|
+
|
|
264
|
+
// Keep the latest content pinned to the bottom of the terminal so streaming
|
|
265
|
+
// never re-scrolls through older messages (which caused a flash).
|
|
266
|
+
const rows = process.stdout.isTTY && process.stdout.rows > 8 ? process.stdout.rows : 999;
|
|
267
|
+
const lines = out.join('\n').split('\n').slice(1);
|
|
268
|
+
const body = lines.length > rows ? lines.slice(lines.length - rows) : lines;
|
|
269
|
+
process.stdout.write('\x1b[2J\x1b[H' + body.join('\n') + '\n');
|
|
270
|
+
}
|
|
271
|
+
}
|