vibehacker 4.1.0 → 4.2.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/README.md +381 -43
- package/package.json +1 -1
- package/src/app.js +29 -76
- package/src/approve.js +12 -17
- package/src/providers.js +2 -2
- package/src/src/agent.js +311 -0
- package/src/src/api.js +314 -0
- package/src/src/app.js +2151 -0
- package/src/src/approve.js +212 -0
- package/src/src/auth.js +45 -0
- package/src/src/config.js +59 -0
- package/src/src/models.js +218 -0
- package/src/src/providers.js +387 -0
- package/src/src/setup.js +95 -0
- package/src/src/supabase.js +287 -0
- package/src/src/tools.js +588 -0
- package/src/src/welcome.js +119 -0
package/src/src/app.js
ADDED
|
@@ -0,0 +1,2151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// WSL / terminal compatibility — must be set before blessed loads
|
|
4
|
+
if (!process.env.TERM || process.env.TERM === 'dumb') {
|
|
5
|
+
process.env.TERM = 'xterm-256color';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const blessed = require('blessed');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { Agent, MODES } = require('./agent');
|
|
11
|
+
const { ModelManager } = require('./models');
|
|
12
|
+
const { ProviderManager, PROVIDERS } = require('./providers');
|
|
13
|
+
const { ERR, isCircuitOpen, getHealth } = require('./api');
|
|
14
|
+
const { showWelcome } = require('./welcome');
|
|
15
|
+
const { showApproval, NEEDS_APPROVAL, getAllowKey } = require('./approve');
|
|
16
|
+
const { showSetup } = require('./setup');
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function esc(str) {
|
|
21
|
+
return String(str).replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// XmlStreamFilter — strips tool XML blocks (raw or ```xml fenced) from
|
|
25
|
+
// streamed AI output so nothing leaks on screen.
|
|
26
|
+
// .feed(token) → visible text to display (may be empty string)
|
|
27
|
+
// .reset() → call between tool iterations
|
|
28
|
+
// Sync tool names from tools.js + add thinking block support
|
|
29
|
+
const { TOOL_TAG_NAMES } = require('./tools');
|
|
30
|
+
const TOOL_NAMES = [...TOOL_TAG_NAMES];
|
|
31
|
+
// Suppressed block tags: tool calls AND thinking blocks
|
|
32
|
+
const SUPPRESSED_NAMES = [...TOOL_NAMES, 'think', 'thinking'];
|
|
33
|
+
const TOOL_OPEN_TAGS = SUPPRESSED_NAMES.map(n => `<${n}>`);
|
|
34
|
+
const TOOL_CLOSE_TAGS = SUPPRESSED_NAMES.map(n => `</${n}>`);
|
|
35
|
+
// Also catch ``` ```xml fenced variants
|
|
36
|
+
const FENCE_OPENERS = ['```xml\n', '```xml\r\n', '```\n<', '```\r\n<'];
|
|
37
|
+
|
|
38
|
+
// Pre-compute max tag length for safe buffer holdback
|
|
39
|
+
const _maxTagLen = Math.max(...TOOL_OPEN_TAGS.map(t => t.length), ...FENCE_OPENERS.map(t => t.length), 1);
|
|
40
|
+
|
|
41
|
+
class XmlStreamFilter {
|
|
42
|
+
constructor() { this.reset(); }
|
|
43
|
+
|
|
44
|
+
reset() {
|
|
45
|
+
this._chunks = []; // array-based accumulation (O(1) append vs O(n) string concat)
|
|
46
|
+
this._buf = ''; // materialized only when scanning
|
|
47
|
+
this._emitted = 0;
|
|
48
|
+
this._inBlock = false;
|
|
49
|
+
this._fence = false;
|
|
50
|
+
this._dirty = false; // tracks if _chunks has new data since last _materialize
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
feed(token) {
|
|
54
|
+
this._chunks.push(token);
|
|
55
|
+
this._dirty = true;
|
|
56
|
+
return this._drain();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_materialize() {
|
|
60
|
+
if (this._dirty) {
|
|
61
|
+
this._buf += this._chunks.join('');
|
|
62
|
+
this._chunks.length = 0;
|
|
63
|
+
this._dirty = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_drain() {
|
|
68
|
+
this._materialize();
|
|
69
|
+
let out = '';
|
|
70
|
+
|
|
71
|
+
while (true) {
|
|
72
|
+
if (!this._inBlock) {
|
|
73
|
+
const slice = this._buf.substring(this._emitted);
|
|
74
|
+
if (!slice) break;
|
|
75
|
+
|
|
76
|
+
// Find earliest suppressed block opener
|
|
77
|
+
let earliest = -1;
|
|
78
|
+
let isFence = false;
|
|
79
|
+
|
|
80
|
+
for (const tag of TOOL_OPEN_TAGS) {
|
|
81
|
+
const pos = slice.indexOf(tag);
|
|
82
|
+
if (pos !== -1 && (earliest === -1 || pos < earliest)) { earliest = pos; isFence = false; }
|
|
83
|
+
}
|
|
84
|
+
for (const fence of FENCE_OPENERS) {
|
|
85
|
+
const pos = slice.indexOf(fence);
|
|
86
|
+
if (pos !== -1 && (earliest === -1 || pos < earliest)) { earliest = pos; isFence = true; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (earliest === -1) {
|
|
90
|
+
// Emit everything except holdback zone (partial tag guard)
|
|
91
|
+
const safe = Math.max(0, slice.length - _maxTagLen);
|
|
92
|
+
if (safe > 0) { out += slice.substring(0, safe); this._emitted += safe; }
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Emit clean text before block
|
|
97
|
+
const before = slice.substring(0, earliest).replace(/\s+$/, '');
|
|
98
|
+
if (before) out += before + '\n';
|
|
99
|
+
this._emitted += earliest;
|
|
100
|
+
this._inBlock = true;
|
|
101
|
+
this._fence = isFence;
|
|
102
|
+
|
|
103
|
+
} else {
|
|
104
|
+
const slice = this._buf.substring(this._emitted);
|
|
105
|
+
let found = -1, foundLen = 0;
|
|
106
|
+
|
|
107
|
+
if (this._fence) {
|
|
108
|
+
const fc = slice.indexOf('\n```');
|
|
109
|
+
if (fc !== -1) { found = fc + 4; if (this._buf[this._emitted + found] === '\n') found++; }
|
|
110
|
+
} else {
|
|
111
|
+
for (const tag of TOOL_CLOSE_TAGS) {
|
|
112
|
+
const pos = slice.indexOf(tag);
|
|
113
|
+
if (pos !== -1 && (found === -1 || pos < found)) { found = pos; foundLen = tag.length; }
|
|
114
|
+
}
|
|
115
|
+
if (found !== -1) { found += foundLen; if (this._buf[this._emitted + found] === '\n') found++; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (found === -1) break;
|
|
119
|
+
this._emitted += found;
|
|
120
|
+
this._inBlock = false;
|
|
121
|
+
this._fence = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Compact buffer periodically to prevent memory growth
|
|
126
|
+
if (this._emitted > 8192) {
|
|
127
|
+
this._buf = this._buf.substring(this._emitted);
|
|
128
|
+
this._emitted = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Icon per tool name
|
|
136
|
+
function _toolIcon(name) {
|
|
137
|
+
const icons = {
|
|
138
|
+
read_file: '📄',
|
|
139
|
+
edit_file: '✏️ ',
|
|
140
|
+
write_file: '📝',
|
|
141
|
+
execute_command: '⚡',
|
|
142
|
+
list_files: '📂',
|
|
143
|
+
search_files: '🔍',
|
|
144
|
+
grep: '🔎',
|
|
145
|
+
glob: '📂',
|
|
146
|
+
create_directory: '📁',
|
|
147
|
+
delete_file: '🗑 ',
|
|
148
|
+
};
|
|
149
|
+
return icons[name] || '🔧';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Format tool args into a short human-readable summary
|
|
153
|
+
function _fmtToolArgs(name, args) {
|
|
154
|
+
if (!args) return '';
|
|
155
|
+
if (name === 'read_file') {
|
|
156
|
+
let s = args.path || '';
|
|
157
|
+
if (args.offset || args.limit) s += ` (lines ${args.offset || 1}-${(args.offset || 1) + (args.limit || 0)})`;
|
|
158
|
+
return s;
|
|
159
|
+
}
|
|
160
|
+
if (name === 'edit_file') return `${args.path} (${args.replace_all ? 'replace all' : 'edit'})`;
|
|
161
|
+
if (name === 'delete_file' || name === 'create_directory') return args.path || '';
|
|
162
|
+
if (name === 'write_file') return `${args.path} (${args.content ? args.content.split('\n').length + ' lines' : ''})`;
|
|
163
|
+
if (name === 'execute_command') return args.command || '';
|
|
164
|
+
if (name === 'list_files') return `${args.path || '.'}${args.recursive ? ' (recursive)' : ''}`;
|
|
165
|
+
if (name === 'search_files' || name === 'grep') return `"${args.pattern}" in ${args.path || '.'}`;
|
|
166
|
+
if (name === 'glob') return args.pattern || '';
|
|
167
|
+
return JSON.stringify(args).substring(0, 60);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fmtCtx(n) {
|
|
171
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
172
|
+
if (n >= 1000) return `${Math.floor(n / 1000)}k`;
|
|
173
|
+
return String(n);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── App ──────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
class HackerCLIApp {
|
|
179
|
+
constructor() {
|
|
180
|
+
this.agent = new Agent();
|
|
181
|
+
this.models = new ModelManager();
|
|
182
|
+
this.providers = new ProviderManager(this.models); // Multi-provider rotation
|
|
183
|
+
this.modeIndex = 0;
|
|
184
|
+
this.inputHistory = [];
|
|
185
|
+
this.historyIndex = -1;
|
|
186
|
+
this.isProcessing = false;
|
|
187
|
+
this._spinnerTimer = null;
|
|
188
|
+
this._menuOpen = false;
|
|
189
|
+
this._abortCtrl = null;
|
|
190
|
+
this._inputBuf = '';
|
|
191
|
+
this._lastMessage = ''; // for Ctrl+R retry
|
|
192
|
+
this._dailyLimitHit = false; // all providers exhausted
|
|
193
|
+
this._alwaysAllowed = new Set(); // keys of always-approved tool patterns
|
|
194
|
+
this._lastToolContext = ''; // last AI prose before a tool call (for dialog context)
|
|
195
|
+
// Animation state
|
|
196
|
+
this._tokenCount = 0;
|
|
197
|
+
this._reqStartTime = null;
|
|
198
|
+
this._toolStepCount = 0;
|
|
199
|
+
// no header animation
|
|
200
|
+
// Retry tracking — prevents infinite loops
|
|
201
|
+
this._triedModels = new Set(); // model IDs tried this request
|
|
202
|
+
this._pinnedModel = false; // true once user manually picks a model — disables silent auto-switching
|
|
203
|
+
this._inCodeBlock = false; // tracks ``` state during streaming for clean code rendering
|
|
204
|
+
this._codeLang = ''; // language tag of active code fence
|
|
205
|
+
this._renderPending = false; // coalesces screen.render() calls into ~60fps
|
|
206
|
+
this._retryCount = 0; // total retries this request
|
|
207
|
+
this._maxRetries = 6; // hard cap on auto-retries
|
|
208
|
+
// Tier info (from Supabase)
|
|
209
|
+
this._tier = null; // 'free' | 'pro' | null (unknown)
|
|
210
|
+
this._remaining = null; // requests remaining today (-1 = unlimited)
|
|
211
|
+
this._dailyLimit = null; // daily limit
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Init ─────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
async start() {
|
|
217
|
+
const headerH = 3; // single line + border
|
|
218
|
+
|
|
219
|
+
this.screen = blessed.screen({
|
|
220
|
+
smartCSR: true,
|
|
221
|
+
title: 'Vibe Hacker — Cybersecurity Assistant',
|
|
222
|
+
fullUnicode: true,
|
|
223
|
+
forceUnicode: true,
|
|
224
|
+
dockBorders: true,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Header — single compact bar ──
|
|
228
|
+
this.header = blessed.box({
|
|
229
|
+
top: 0, left: 0, width: '100%', height: headerH,
|
|
230
|
+
tags: true,
|
|
231
|
+
content: this._buildHeaderContent(),
|
|
232
|
+
border: { type: 'line' },
|
|
233
|
+
style: { fg: '#00ff88', bg: 'black', border: { fg: '#005500' } },
|
|
234
|
+
padding: { left: 1 },
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── Chat log ──
|
|
238
|
+
this.chat = blessed.log({
|
|
239
|
+
top: headerH, left: 0, width: '100%',
|
|
240
|
+
height: `100%-${headerH + 5}`,
|
|
241
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
242
|
+
mouse: true,
|
|
243
|
+
scrollbar: { ch: '▐', style: { fg: '#003300', bg: 'black' } },
|
|
244
|
+
border: { type: 'line' },
|
|
245
|
+
style: { fg: '#00ff00', bg: 'black', border: { fg: '#003300' } },
|
|
246
|
+
padding: { left: 1, right: 1 },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── Input wrap ──
|
|
250
|
+
this.inputWrap = blessed.box({
|
|
251
|
+
bottom: 1, left: 0, width: '100%', height: 4,
|
|
252
|
+
tags: true,
|
|
253
|
+
border: { type: 'line' },
|
|
254
|
+
label: ' {#00ff88-fg}{bold} ❯ {/bold}{/#00ff88-fg} ',
|
|
255
|
+
style: { fg: '#00ff00', bg: 'black', border: { fg: '#00aa44' } },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// CRITICAL: inputOnFocus MUST be false — it steals raw keypresses from
|
|
259
|
+
// overlay lists. We manage keyboard input manually via screen.on('keypress').
|
|
260
|
+
this.inputBox = blessed.textarea({
|
|
261
|
+
parent: this.inputWrap,
|
|
262
|
+
top: 0, left: 0, width: '100%-2', height: 2,
|
|
263
|
+
inputOnFocus: false,
|
|
264
|
+
mouse: true,
|
|
265
|
+
style: { fg: '#00ff88', bg: 'black' },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
this.modeBar = blessed.text({
|
|
269
|
+
parent: this.inputWrap,
|
|
270
|
+
bottom: 0, left: 1, width: '100%-3', height: 1,
|
|
271
|
+
tags: true,
|
|
272
|
+
content: this._modeBarContent(),
|
|
273
|
+
style: { fg: '#00aa00', bg: 'black' },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── Pet bar (bottom — animated mascot) ──
|
|
277
|
+
this.petBar = blessed.box({
|
|
278
|
+
bottom: 0, left: 0, width: '100%', height: 1,
|
|
279
|
+
tags: true,
|
|
280
|
+
content: '',
|
|
281
|
+
style: { fg: '#003300', bg: 'black' },
|
|
282
|
+
padding: { left: 1 },
|
|
283
|
+
});
|
|
284
|
+
this._startPet();
|
|
285
|
+
|
|
286
|
+
this.screen.append(this.header);
|
|
287
|
+
this.screen.append(this.chat);
|
|
288
|
+
this.screen.append(this.inputWrap);
|
|
289
|
+
this.screen.append(this.petBar);
|
|
290
|
+
|
|
291
|
+
this._bindKeys();
|
|
292
|
+
this.screen.render();
|
|
293
|
+
this.inputBox.focus();
|
|
294
|
+
this._syncInputDisplay();
|
|
295
|
+
this._refreshHeader();
|
|
296
|
+
|
|
297
|
+
// ── First-run setup — each user provides their own API key ──
|
|
298
|
+
// This is critical for production: no shared keys, each user = own limits
|
|
299
|
+
const cfg = require('./config');
|
|
300
|
+
if (!cfg.hasKey()) {
|
|
301
|
+
const key = await showSetup(this.screen);
|
|
302
|
+
if (key) {
|
|
303
|
+
this.providers.add(key);
|
|
304
|
+
this.providers.clearLimits();
|
|
305
|
+
this._updateModeBar();
|
|
306
|
+
this._freshSetup = true; // skip silent key check — model fetch validates key
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Welcome screen — fast fade-in logo ──
|
|
311
|
+
await showWelcome(this.screen);
|
|
312
|
+
|
|
313
|
+
// ── Post-welcome ──
|
|
314
|
+
this.inputBox.focus();
|
|
315
|
+
this._syncInputDisplay();
|
|
316
|
+
|
|
317
|
+
const _cwd = (() => { try { const c = process.cwd(); return c || ''; } catch (_) { return ''; } })() || require('os').homedir() || '/';
|
|
318
|
+
this.agent.setCwd(_cwd);
|
|
319
|
+
|
|
320
|
+
// ── Professional disclaimer + greeting ──
|
|
321
|
+
this._log('');
|
|
322
|
+
this._log(' {green-fg}{bold}◈ Vibe Hacker{/bold}{/green-fg} {#00aa66-fg}Cybersecurity Assistant{/#00aa66-fg}');
|
|
323
|
+
this._log(' {#333333-fg}────────────────────────────────────────────────{/#333333-fg}');
|
|
324
|
+
this._log(' {#888888-fg}Vibe Hacker is a professional-grade assistant for{/#888888-fg}');
|
|
325
|
+
this._log(' {#888888-fg}authorized security testing and research only.{/#888888-fg}');
|
|
326
|
+
this._log('');
|
|
327
|
+
this._log(' {#666666-fg}By using this tool you acknowledge that you{/#666666-fg}');
|
|
328
|
+
this._log(' {#666666-fg}are solely responsible for your actions. The{/#666666-fg}');
|
|
329
|
+
this._log(' {#666666-fg}authors assume no liability for misuse, damage,{/#666666-fg}');
|
|
330
|
+
this._log(' {#666666-fg}or unlawful activity of any kind.{/#666666-fg}');
|
|
331
|
+
this._log(' {#333333-fg}────────────────────────────────────────────────{/#333333-fg}');
|
|
332
|
+
this._log('');
|
|
333
|
+
this._log(' {#00ff88-fg}{bold}Hello hacker — what are we vibing today?{/bold}{/#00ff88-fg}');
|
|
334
|
+
this._log(' {#444444-fg}Type your task, or press / for commands.{/#444444-fg}');
|
|
335
|
+
this._log('');
|
|
336
|
+
this.screen.render();
|
|
337
|
+
|
|
338
|
+
// Always silently refresh models on startup — no picker, no prompts
|
|
339
|
+
const cfg2 = require('./config');
|
|
340
|
+
if (cfg2.hasKey()) {
|
|
341
|
+
this._silentModelRefresh();
|
|
342
|
+
this._checkTier();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Background update check — delayed 3s so it doesn't slow the first render
|
|
346
|
+
setTimeout(() => this._checkForUpdate(), 3000);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Key binding ───────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
_bindKeys() {
|
|
352
|
+
const sc = this.screen;
|
|
353
|
+
|
|
354
|
+
// Global keys (always active)
|
|
355
|
+
// Ctrl+C — exit immediately (blessed may intercept SIGINT before process handler)
|
|
356
|
+
sc.key(['C-c'], () => this._exit());
|
|
357
|
+
sc.key(['C-l'], () => {
|
|
358
|
+
this.chat.setContent('');
|
|
359
|
+
this._log('{#444444-fg}[cleared]{/#444444-fg}');
|
|
360
|
+
sc.render();
|
|
361
|
+
});
|
|
362
|
+
sc.key(['C-p'], () => { if (!this._menuOpen) this._showPalette(); });
|
|
363
|
+
// Model picker
|
|
364
|
+
sc.key(['C-n'], () => { if (!this._menuOpen && !this.isProcessing) this._showModelPicker(); });
|
|
365
|
+
// Retry last message
|
|
366
|
+
sc.key(['C-r'], () => {
|
|
367
|
+
if (this.isProcessing || !this._lastMessage) return;
|
|
368
|
+
this._log('{#444444-fg}[retrying last message…]{/#444444-fg}');
|
|
369
|
+
this.screen.render();
|
|
370
|
+
this._submit(this._lastMessage);
|
|
371
|
+
});
|
|
372
|
+
// Cancel in-progress request
|
|
373
|
+
sc.key(['C-x'], () => {
|
|
374
|
+
if (this.isProcessing) {
|
|
375
|
+
if (this._abortCtrl) this._abortCtrl.abort();
|
|
376
|
+
this.isProcessing = false;
|
|
377
|
+
this._stopSpinner();
|
|
378
|
+
this._log('{yellow-fg}[cancelled]{/yellow-fg}');
|
|
379
|
+
this.screen.render();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
sc.key(['tab'], () => {
|
|
384
|
+
if (this.isProcessing || this._menuOpen) return;
|
|
385
|
+
this.modeIndex = (this.modeIndex + 1) % MODES.length;
|
|
386
|
+
this.agent.setMode(MODES[this.modeIndex].id);
|
|
387
|
+
this._updateModeBar();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Smooth scrolling — scroll by a few lines, not half-page jumps
|
|
391
|
+
const SCROLL_LINES = 3;
|
|
392
|
+
const PAGE_LINES = () => Math.max(3, Math.floor(this.chat.height / 2));
|
|
393
|
+
sc.key(['pageup'], () => { this.chat.scroll(-PAGE_LINES()); sc.render(); });
|
|
394
|
+
sc.key(['pagedown'], () => { this.chat.scroll( PAGE_LINES()); sc.render(); });
|
|
395
|
+
|
|
396
|
+
// Mouse wheel scrolling — smooth 3-line increments
|
|
397
|
+
this.chat.on('wheelup', () => { this.chat.scroll(-SCROLL_LINES); this.screen.render(); });
|
|
398
|
+
this.chat.on('wheeldown', () => { this.chat.scroll( SCROLL_LINES); this.screen.render(); });
|
|
399
|
+
|
|
400
|
+
// ── Manual keyboard input (bypasses inputOnFocus raw capture) ──
|
|
401
|
+
sc.on('keypress', (ch, key) => {
|
|
402
|
+
// Route to menu handler when overlay is open
|
|
403
|
+
if (this._menuOpen) {
|
|
404
|
+
if (this._menuKeyHandler) this._menuKeyHandler(ch, key);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!key || key.ctrl || key.meta) return;
|
|
408
|
+
|
|
409
|
+
const name = key.name;
|
|
410
|
+
|
|
411
|
+
if (name === 'enter' || name === 'return') {
|
|
412
|
+
if (this.isProcessing) return;
|
|
413
|
+
const val = this._inputBuf.trim();
|
|
414
|
+
if (val) this._submit(val);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (name === 'escape') {
|
|
419
|
+
this._inputBuf = '';
|
|
420
|
+
this.historyIndex = -1;
|
|
421
|
+
this._syncInputDisplay();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (name === 'backspace') {
|
|
426
|
+
this._inputBuf = this._inputBuf.slice(0, -1);
|
|
427
|
+
this._syncInputDisplay();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (name === 'up') {
|
|
432
|
+
if (!this.inputHistory.length) return;
|
|
433
|
+
if (this.historyIndex < this.inputHistory.length - 1) {
|
|
434
|
+
this.historyIndex++;
|
|
435
|
+
this._inputBuf = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex];
|
|
436
|
+
this._syncInputDisplay();
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (name === 'down') {
|
|
442
|
+
if (this.historyIndex > 0) {
|
|
443
|
+
this.historyIndex--;
|
|
444
|
+
this._inputBuf = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex];
|
|
445
|
+
} else {
|
|
446
|
+
this.historyIndex = -1;
|
|
447
|
+
this._inputBuf = '';
|
|
448
|
+
}
|
|
449
|
+
this._syncInputDisplay();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Slash menu trigger — clear input immediately, open menu
|
|
454
|
+
if (ch === '/' && this._inputBuf === '') {
|
|
455
|
+
if (!this._menuOpen) {
|
|
456
|
+
this._inputBuf = '';
|
|
457
|
+
this._syncInputDisplay();
|
|
458
|
+
this._showSlashMenu();
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Regular char
|
|
464
|
+
if (ch && ch.length === 1) {
|
|
465
|
+
this._inputBuf += ch;
|
|
466
|
+
this._syncInputDisplay();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Sync the visible textarea content with our manual buffer
|
|
472
|
+
_syncInputDisplay() {
|
|
473
|
+
this.inputBox.setValue(this._inputBuf);
|
|
474
|
+
// Update input label to show prompt indicator
|
|
475
|
+
const label = this.isProcessing
|
|
476
|
+
? ' {#444444-fg}processing…{/#444444-fg} '
|
|
477
|
+
: ' {#00ff88-fg}{bold} ❯ {/bold}{/#00ff88-fg} ';
|
|
478
|
+
this.inputWrap.setLabel(label);
|
|
479
|
+
|
|
480
|
+
// Dynamic height — expand input box for long / multi-line text
|
|
481
|
+
const cols = (this.screen.width || 80) - 4; // account for border + padding
|
|
482
|
+
const lines = this._inputBuf.split('\n');
|
|
483
|
+
let visualLines = 0;
|
|
484
|
+
for (const ln of lines) {
|
|
485
|
+
visualLines += Math.max(1, Math.ceil((ln.length || 1) / Math.max(1, cols)));
|
|
486
|
+
}
|
|
487
|
+
const innerH = Math.min(10, Math.max(1, visualLines)); // 1–10 lines
|
|
488
|
+
const wrapH = innerH + 3; // border(2) + modeBar(1)
|
|
489
|
+
if (this.inputBox.height !== innerH) {
|
|
490
|
+
this.inputBox.height = innerH;
|
|
491
|
+
this.inputWrap.height = wrapH;
|
|
492
|
+
}
|
|
493
|
+
this.screen.render();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Display helpers ───────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
_modeBarContent() {
|
|
499
|
+
const mode = MODES[this.modeIndex];
|
|
500
|
+
const provider = this.providers.current();
|
|
501
|
+
const model = this.providers.currentModel();
|
|
502
|
+
|
|
503
|
+
// Account tier badge
|
|
504
|
+
let acctBadge;
|
|
505
|
+
if (this._tier === 'pro') {
|
|
506
|
+
acctBadge = '{#ffaa00-fg}PRO{/#ffaa00-fg}';
|
|
507
|
+
} else if (this._tier === 'free') {
|
|
508
|
+
acctBadge = '{#00cc44-fg}FREE{/#00cc44-fg}';
|
|
509
|
+
} else {
|
|
510
|
+
acctBadge = this.providers.isCurrentFree()
|
|
511
|
+
? '{#00cc44-fg}FREE{/#00cc44-fg}'
|
|
512
|
+
: '{yellow-fg}PAID{/yellow-fg}';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Show "Vibe Model" — never expose which underlying model is used
|
|
516
|
+
return (
|
|
517
|
+
`{cyan-fg}{bold}${mode.name}{/bold}{/cyan-fg} ` +
|
|
518
|
+
`{#1a3a1a-fg}│{/#1a3a1a-fg} ` +
|
|
519
|
+
`{#44ff88-fg}Vibe Hacker{/#44ff88-fg} ` +
|
|
520
|
+
`{#1a3a1a-fg}›{/#1a3a1a-fg} ` +
|
|
521
|
+
`{#88ff88-fg}Vibe Model{/#88ff88-fg} ` +
|
|
522
|
+
`{#1a3a1a-fg}[{/#1a3a1a-fg}${acctBadge}{#1a3a1a-fg}]{/#1a3a1a-fg}`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── Animated pet mascot ────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
_startPet() {
|
|
529
|
+
const petFrames = [
|
|
530
|
+
['(⟐)', ' ⟐ ', '(⟐)', ' ⟐ '], // idle blink
|
|
531
|
+
['(⟐) ~', '(⟐) ~~', '(⟐)~~~', '(⟐) ~~', '(⟐) ~'], // thinking waves
|
|
532
|
+
['{#00ff88-fg}(⟐){/#00ff88-fg}', '{#00cc66-fg}(⟐){/#00cc66-fg}', '{#009944-fg}(⟐){/#009944-fg}', '{#00cc66-fg}(⟐){/#00cc66-fg}'], // pulse
|
|
533
|
+
];
|
|
534
|
+
this._petMode = 0; // 0=idle, 1=thinking, 2=pulse
|
|
535
|
+
this._petFrame = 0;
|
|
536
|
+
this._petPos = 0;
|
|
537
|
+
this._petDir = 1;
|
|
538
|
+
|
|
539
|
+
const w = () => (this.screen && this.screen.width) ? this.screen.width - 4 : 76;
|
|
540
|
+
|
|
541
|
+
this._petTimer = setInterval(() => {
|
|
542
|
+
// Skip pet updates while streaming — avoids render contention with tokens
|
|
543
|
+
if (this.isProcessing && this._petMode !== 1) return;
|
|
544
|
+
this._petFrame++;
|
|
545
|
+
const frames = petFrames[this._petMode] || petFrames[0];
|
|
546
|
+
const pet = frames[this._petFrame % frames.length];
|
|
547
|
+
|
|
548
|
+
// Slowly wander left/right
|
|
549
|
+
if (this._petFrame % 3 === 0) {
|
|
550
|
+
this._petPos += this._petDir;
|
|
551
|
+
const maxPos = w() - 20;
|
|
552
|
+
if (this._petPos >= maxPos) this._petDir = -1;
|
|
553
|
+
if (this._petPos <= 0) this._petDir = 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const pad = ' '.repeat(Math.max(0, this._petPos));
|
|
557
|
+
const msg = this._petMsg || '';
|
|
558
|
+
this.petBar.setContent(
|
|
559
|
+
`${pad}{#004400-fg}${pet}{/#004400-fg}` +
|
|
560
|
+
(msg ? ` {#333333-fg}${msg}{/#333333-fg}` : '')
|
|
561
|
+
);
|
|
562
|
+
this._scheduleRender();
|
|
563
|
+
}, 600);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
_setPetMode(mode, msg) {
|
|
567
|
+
this._petMode = mode;
|
|
568
|
+
this._petMsg = msg || '';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_updateModeBar() {
|
|
572
|
+
this.modeBar.setContent(this._modeBarContent());
|
|
573
|
+
this._refreshHeader();
|
|
574
|
+
this.screen.render();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
_log(text) {
|
|
578
|
+
this.chat.log(text);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
_startSpinner(msg) {
|
|
582
|
+
this._setPetMode(1, msg); // thinking mode
|
|
583
|
+
const spinStart = this._reqStartTime || Date.now();
|
|
584
|
+
clearInterval(this._spinnerTimer);
|
|
585
|
+
this._spinnerTimer = setInterval(() => {
|
|
586
|
+
const elapsed = ((Date.now() - spinStart) / 1000).toFixed(1);
|
|
587
|
+
const tokStr = this._tokenCount > 0 ? ` · ${this._tokenCount} tok` : '';
|
|
588
|
+
const stepStr = this._toolStepCount > 0 ? ` · step ${this._toolStepCount}` : '';
|
|
589
|
+
this._setPetMode(1, `${msg} ${elapsed}s${tokStr}${stepStr}`);
|
|
590
|
+
}, 500);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
_stopSpinner() {
|
|
594
|
+
clearInterval(this._spinnerTimer);
|
|
595
|
+
this._spinnerTimer = null;
|
|
596
|
+
this._setPetMode(0, ''); // back to idle
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Header content builder ────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
_buildHeaderContent() {
|
|
602
|
+
const model = this.providers ? this.providers.currentModel() : null;
|
|
603
|
+
const modelName = model ? 'Vibe Model' : '—';
|
|
604
|
+
const mode = MODES[this.modeIndex];
|
|
605
|
+
|
|
606
|
+
// Tier badge with remaining requests
|
|
607
|
+
let tierBadge = '';
|
|
608
|
+
if (this._tier === 'pro') {
|
|
609
|
+
tierBadge = '{#ffaa00-fg}{bold}PRO{/bold}{/#ffaa00-fg}';
|
|
610
|
+
} else {
|
|
611
|
+
const limit = this._dailyLimit || 50;
|
|
612
|
+
const r = this._remaining !== null && this._remaining !== undefined ? this._remaining : limit;
|
|
613
|
+
const color = r > 25 ? '#00cc44' : r > 10 ? '#ffaa00' : r > 0 ? '#ff6600' : '#ff4444';
|
|
614
|
+
tierBadge = `{${color}-fg}${r}/${limit}{/${color}-fg}`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
` {#00ff88-fg}{bold}vibsecurity.com{/bold}{/#00ff88-fg}` +
|
|
619
|
+
` {#003300-fg}│{/#003300-fg} ` +
|
|
620
|
+
`{cyan-fg}{bold}${mode.name}{/bold}{/cyan-fg}` +
|
|
621
|
+
` {#003300-fg}│{/#003300-fg} ` +
|
|
622
|
+
`{#008844-fg}${esc(modelName)}{/#008844-fg}` +
|
|
623
|
+
(tierBadge ? ` {#003300-fg}│{/#003300-fg} ${tierBadge}` : '')
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Animated header ──────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
_refreshHeader() {
|
|
630
|
+
try {
|
|
631
|
+
this.header.setContent(this._buildHeaderContent());
|
|
632
|
+
this.screen.render();
|
|
633
|
+
} catch (_) {}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── Overlay helper — menu overlay ─────────────────────────────────────────
|
|
637
|
+
// Returns promise → selected index or -1.
|
|
638
|
+
// Key handling is on SCREEN level (blessed list with keys:false doesn't
|
|
639
|
+
// receive keypress events). We intercept in _bindKeys via _menuKeyHandler.
|
|
640
|
+
|
|
641
|
+
_openList({ label, items, selectedIndex = 0, width: customWidth, skipFn }) {
|
|
642
|
+
return new Promise((resolve) => {
|
|
643
|
+
this._menuOpen = true;
|
|
644
|
+
const sc = this.screen;
|
|
645
|
+
|
|
646
|
+
const listW = customWidth || Math.min(54, sc.width - 6);
|
|
647
|
+
const listH = Math.min(items.length + 2, sc.height - 6);
|
|
648
|
+
|
|
649
|
+
// ── List ──
|
|
650
|
+
const list = blessed.list({
|
|
651
|
+
parent: sc,
|
|
652
|
+
top: 'center',
|
|
653
|
+
left: 'center',
|
|
654
|
+
width: listW,
|
|
655
|
+
height: listH,
|
|
656
|
+
tags: true,
|
|
657
|
+
keys: false,
|
|
658
|
+
vi: false,
|
|
659
|
+
mouse: true,
|
|
660
|
+
scrollable: true, alwaysScroll: true,
|
|
661
|
+
border: { type: 'line' },
|
|
662
|
+
label: ` ${label} `,
|
|
663
|
+
scrollbar: { ch: '▐', style: { fg: '#003300', bg: 'black' } },
|
|
664
|
+
style: {
|
|
665
|
+
fg: '#88ff88', bg: '#000a00',
|
|
666
|
+
border: { fg: '#00aa44' },
|
|
667
|
+
selected: { fg: '#000000', bg: '#00ff88', bold: true },
|
|
668
|
+
item: { fg: '#77dd77', hover: { fg: '#00ff88' } },
|
|
669
|
+
label: { fg: '#00ff88', bold: true },
|
|
670
|
+
},
|
|
671
|
+
items,
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Skip header rows on initial selection
|
|
675
|
+
let initIdx = selectedIndex;
|
|
676
|
+
if (skipFn) {
|
|
677
|
+
while (initIdx < items.length && skipFn(initIdx)) initIdx++;
|
|
678
|
+
if (initIdx >= items.length) initIdx = 0;
|
|
679
|
+
}
|
|
680
|
+
list.select(initIdx);
|
|
681
|
+
list.focus();
|
|
682
|
+
sc.render();
|
|
683
|
+
|
|
684
|
+
let resolved = false;
|
|
685
|
+
const done = (idx) => {
|
|
686
|
+
if (resolved) return;
|
|
687
|
+
resolved = true;
|
|
688
|
+
this._menuOpen = false;
|
|
689
|
+
this._menuKeyHandler = null;
|
|
690
|
+
try { list.destroy(); } catch (_) {}
|
|
691
|
+
this.inputBox.focus();
|
|
692
|
+
sc.render();
|
|
693
|
+
resolve(idx);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Install screen-level key handler for this menu
|
|
697
|
+
this._menuKeyHandler = (ch, key) => {
|
|
698
|
+
if (resolved) return;
|
|
699
|
+
const name = key ? key.name : '';
|
|
700
|
+
|
|
701
|
+
const moveSkip = (dir) => {
|
|
702
|
+
if (!skipFn) return;
|
|
703
|
+
let i = list.selected;
|
|
704
|
+
while (i + dir >= 0 && i + dir < items.length && skipFn(i + dir)) {
|
|
705
|
+
if (dir > 0) list.down(1); else list.up(1);
|
|
706
|
+
i += dir;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
if (name === 'up') {
|
|
711
|
+
list.up(1);
|
|
712
|
+
if (skipFn && skipFn(list.selected)) moveSkip(-1);
|
|
713
|
+
sc.render();
|
|
714
|
+
} else if (name === 'down') {
|
|
715
|
+
list.down(1);
|
|
716
|
+
if (skipFn && skipFn(list.selected)) moveSkip(1);
|
|
717
|
+
sc.render();
|
|
718
|
+
} else if (name === 'enter' || name === 'return') {
|
|
719
|
+
if (skipFn && skipFn(list.selected)) return; // ignore enter on headers
|
|
720
|
+
done(list.selected);
|
|
721
|
+
} else if (name === 'escape') {
|
|
722
|
+
done(-1);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// Mouse click on item — respect skipFn
|
|
727
|
+
list.on('select', (_, idx) => {
|
|
728
|
+
if (skipFn && skipFn(idx)) return;
|
|
729
|
+
done(idx);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── Submit / run ──────────────────────────────────────────────────────────
|
|
735
|
+
|
|
736
|
+
async _submit(text) {
|
|
737
|
+
if (this.inputHistory[this.inputHistory.length - 1] !== text) {
|
|
738
|
+
this.inputHistory.push(text);
|
|
739
|
+
}
|
|
740
|
+
this.historyIndex = -1;
|
|
741
|
+
this._inputBuf = '';
|
|
742
|
+
this._syncInputDisplay();
|
|
743
|
+
|
|
744
|
+
if (text.startsWith('/')) {
|
|
745
|
+
this._runSlashCommand(text);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
this._lastMessage = text; // save for Ctrl+R retry
|
|
750
|
+
|
|
751
|
+
// Clean user message block
|
|
752
|
+
this._log('');
|
|
753
|
+
this._log('{#003300-fg} ┌' + '─'.repeat(54) + '{/#003300-fg}');
|
|
754
|
+
this._log(`{#003300-fg} │{/#003300-fg} {cyan-fg}{bold}❯ You{/bold}{/cyan-fg} {#ccffcc-fg}${esc(text)}{/#ccffcc-fg}`);
|
|
755
|
+
this._log('{#003300-fg} └' + '─'.repeat(54) + '{/#003300-fg}');
|
|
756
|
+
this._log('');
|
|
757
|
+
|
|
758
|
+
// Reset animation & retry counters
|
|
759
|
+
this._tokenCount = 0;
|
|
760
|
+
this._reqStartTime = Date.now();
|
|
761
|
+
this._toolStepCount = 0;
|
|
762
|
+
this._triedModels.clear();
|
|
763
|
+
this._retryCount = 0;
|
|
764
|
+
// Reset stream-local formatting state
|
|
765
|
+
this._inCodeBlock = false;
|
|
766
|
+
this._codeLang = '';
|
|
767
|
+
|
|
768
|
+
// Check tier limit — run in background, don't block the request
|
|
769
|
+
// Only block if we already know we're at limit
|
|
770
|
+
if (this._remaining === 0 && this._tier === 'free') {
|
|
771
|
+
const allowed = await this._useRequest();
|
|
772
|
+
if (!allowed) return;
|
|
773
|
+
} else {
|
|
774
|
+
// Fire-and-forget: decrement counter async, don't wait
|
|
775
|
+
this._useRequest().then(allowed => {
|
|
776
|
+
if (!allowed) {
|
|
777
|
+
// Limit just hit — abort if still processing
|
|
778
|
+
if (this._abortCtrl) { try { this._abortCtrl.abort(); } catch (_) {} }
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
this.isProcessing = true;
|
|
784
|
+
const xmlFilter = new XmlStreamFilter();
|
|
785
|
+
|
|
786
|
+
await this._runAgent(text, xmlFilter);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Shared agent runner — used by both _submit and _retryWithCurrentModel ──
|
|
790
|
+
|
|
791
|
+
async _runAgent(text, xmlFilter) {
|
|
792
|
+
this._abortCtrl = new AbortController();
|
|
793
|
+
this._startSpinner('thinking…');
|
|
794
|
+
|
|
795
|
+
let headerPrinted = false;
|
|
796
|
+
let lineBuffer = '';
|
|
797
|
+
|
|
798
|
+
// Display a chunk of visible text (post-filter) line-by-line.
|
|
799
|
+
const displayChunk = (chunk) => {
|
|
800
|
+
if (!chunk) return;
|
|
801
|
+
const parts = chunk.split('\n');
|
|
802
|
+
if (parts.length === 1) {
|
|
803
|
+
lineBuffer += chunk;
|
|
804
|
+
} else {
|
|
805
|
+
lineBuffer += parts[0];
|
|
806
|
+
if (this._inCodeBlock || lineBuffer.trim() || /^\s*`{3}/.test(lineBuffer)) {
|
|
807
|
+
this._log(this._formatLine(lineBuffer));
|
|
808
|
+
}
|
|
809
|
+
lineBuffer = '';
|
|
810
|
+
for (let i = 1; i < parts.length - 1; i++) {
|
|
811
|
+
if (this._inCodeBlock || parts[i].trim() || /^\s*`{3}/.test(parts[i])) {
|
|
812
|
+
this._log(this._formatLine(parts[i]));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
lineBuffer = parts[parts.length - 1];
|
|
816
|
+
}
|
|
817
|
+
this._scheduleRender();
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const flush = () => {
|
|
821
|
+
if (lineBuffer.trim() || /^\s*`{3}/.test(lineBuffer)) {
|
|
822
|
+
this._log(this._formatLine(lineBuffer));
|
|
823
|
+
lineBuffer = '';
|
|
824
|
+
this._scheduleRender();
|
|
825
|
+
}
|
|
826
|
+
lineBuffer = '';
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
await this.agent.run({
|
|
831
|
+
userMessage: text,
|
|
832
|
+
model: this.providers.currentModel(),
|
|
833
|
+
signal: this._abortCtrl.signal,
|
|
834
|
+
|
|
835
|
+
onToken: (token) => {
|
|
836
|
+
this._tokenCount++;
|
|
837
|
+
if (!headerPrinted) {
|
|
838
|
+
headerPrinted = true;
|
|
839
|
+
this._stopSpinner();
|
|
840
|
+
this._log('{#003300-fg} ┌' + '─'.repeat(54) + '{/#003300-fg}');
|
|
841
|
+
this._log('{#003300-fg} │{/#003300-fg} {green-fg}{bold}◈ Vibe Hacker{/bold}{/green-fg}');
|
|
842
|
+
this._log('{#003300-fg} └' + '─'.repeat(54) + '{/#003300-fg}');
|
|
843
|
+
}
|
|
844
|
+
const visible = xmlFilter.feed(token);
|
|
845
|
+
if (visible) {
|
|
846
|
+
displayChunk(visible);
|
|
847
|
+
const lastLine = visible.split('\n').filter(Boolean).pop();
|
|
848
|
+
if (lastLine) this._lastToolContext = lastLine.trim().substring(0, 80);
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
|
|
852
|
+
onDone: () => {
|
|
853
|
+
flush();
|
|
854
|
+
const elapsed = ((Date.now() - this._reqStartTime) / 1000).toFixed(1);
|
|
855
|
+
const stepPart = this._toolStepCount > 0
|
|
856
|
+
? ` {#1a3a1a-fg}│{/#1a3a1a-fg} {#336633-fg}${this._toolStepCount} tool step${this._toolStepCount > 1 ? 's' : ''}{/#336633-fg}`
|
|
857
|
+
: '';
|
|
858
|
+
this._log('');
|
|
859
|
+
this._log(
|
|
860
|
+
` {#003300-fg}──{/#003300-fg} {#00aa44-fg}✓{/#00aa44-fg} {#336633-fg}${elapsed}s · ${this._tokenCount} tok{/#336633-fg}` +
|
|
861
|
+
stepPart +
|
|
862
|
+
` {#003300-fg}${'─'.repeat(36)}{/#003300-fg}`
|
|
863
|
+
);
|
|
864
|
+
this._log('');
|
|
865
|
+
this.isProcessing = false;
|
|
866
|
+
this._abortCtrl = null;
|
|
867
|
+
this._stopSpinner();
|
|
868
|
+
this.screen.render();
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
onError: (err) => {
|
|
872
|
+
flush();
|
|
873
|
+
xmlFilter.reset();
|
|
874
|
+
const errType = err.type || ERR.UNKNOWN;
|
|
875
|
+
const errMsg = err.msg || err.message || String(err);
|
|
876
|
+
|
|
877
|
+
if (errType === ERR.ABORTED) {
|
|
878
|
+
const elapsed = this._reqStartTime
|
|
879
|
+
? ` {#444444-fg}(${((Date.now() - this._reqStartTime) / 1000).toFixed(1)}s){/#444444-fg}`
|
|
880
|
+
: '';
|
|
881
|
+
this._log(`{yellow-fg}⊘ cancelled{/yellow-fg}${elapsed}`);
|
|
882
|
+
this.isProcessing = false;
|
|
883
|
+
this._abortCtrl = null;
|
|
884
|
+
this._stopSpinner();
|
|
885
|
+
this.screen.render();
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (errType === ERR.RATE_LIMIT || errType === ERR.DAILY_LIMIT) {
|
|
890
|
+
const curModel = this.providers.currentModel();
|
|
891
|
+
|
|
892
|
+
if (errType === ERR.DAILY_LIMIT) {
|
|
893
|
+
this._dailyLimitHit = true;
|
|
894
|
+
this.isProcessing = false;
|
|
895
|
+
this._abortCtrl = null;
|
|
896
|
+
this._stopSpinner();
|
|
897
|
+
this._showDailyLimitWarning(errMsg, true);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (curModel) this._triedModels.add(curModel.id);
|
|
902
|
+
this._retryCount++;
|
|
903
|
+
if (this._retryCount > this._maxRetries) {
|
|
904
|
+
this._dailyLimitHit = true;
|
|
905
|
+
this.isProcessing = false;
|
|
906
|
+
this._abortCtrl = null;
|
|
907
|
+
this._stopSpinner();
|
|
908
|
+
this._showDailyLimitWarning(errMsg, true);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const found = this._findNextAvailable();
|
|
912
|
+
if (found) {
|
|
913
|
+
this._updateModeBar();
|
|
914
|
+
this._startSpinner('connecting…');
|
|
915
|
+
this.screen.render();
|
|
916
|
+
this._retryWithCurrentModel(text, xmlFilter, flush);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
this._dailyLimitHit = true;
|
|
920
|
+
this.isProcessing = false;
|
|
921
|
+
this._abortCtrl = null;
|
|
922
|
+
this._stopSpinner();
|
|
923
|
+
this._showDailyLimitWarning(errMsg, false);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (errType === ERR.NOT_FOUND) {
|
|
928
|
+
const curModel = this.providers.currentModel();
|
|
929
|
+
if (curModel) this._triedModels.add(curModel.id);
|
|
930
|
+
this._retryCount++;
|
|
931
|
+
|
|
932
|
+
if (this._retryCount <= this._maxRetries) {
|
|
933
|
+
const found = this._findNextAvailable();
|
|
934
|
+
if (found) {
|
|
935
|
+
this._updateModeBar();
|
|
936
|
+
this._startSpinner('connecting…');
|
|
937
|
+
this.screen.render();
|
|
938
|
+
this._retryWithCurrentModel(text, xmlFilter, flush);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
this._log('{red-fg}✗ No available models found{/red-fg}');
|
|
943
|
+
this._log('{#333333-fg} Run /refresh to update model list, or /addkey to add a provider{/#333333-fg}');
|
|
944
|
+
this.isProcessing = false;
|
|
945
|
+
this._abortCtrl = null;
|
|
946
|
+
this._stopSpinner();
|
|
947
|
+
this.screen.render();
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
let userMsg = errMsg;
|
|
952
|
+
if (errType === ERR.UNKNOWN || !errMsg || errMsg === 'error' || errMsg.includes('undefined')) {
|
|
953
|
+
userMsg = 'Connection issue — check your network or try /retry';
|
|
954
|
+
} else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND') || errMsg.includes('ETIMEDOUT')) {
|
|
955
|
+
userMsg = 'Network error — could not reach the API server';
|
|
956
|
+
} else if (errMsg.includes('401') || errMsg.includes('403')) {
|
|
957
|
+
userMsg = 'API key invalid or expired — run /key to update';
|
|
958
|
+
} else if (errMsg.includes('500') || errMsg.includes('502') || errMsg.includes('503')) {
|
|
959
|
+
userMsg = 'Server error — try again in a moment or switch models with /model';
|
|
960
|
+
}
|
|
961
|
+
this._log(`{red-fg}✗ ${esc(userMsg)}{/red-fg}`);
|
|
962
|
+
this._log('{#333333-fg} Try: /retry · /model · /addkey{/#333333-fg}');
|
|
963
|
+
this.isProcessing = false;
|
|
964
|
+
this._abortCtrl = null;
|
|
965
|
+
this._stopSpinner();
|
|
966
|
+
this.screen.render();
|
|
967
|
+
},
|
|
968
|
+
|
|
969
|
+
onToolCall: (tc) => {
|
|
970
|
+
flush();
|
|
971
|
+
xmlFilter.reset();
|
|
972
|
+
this._toolStepCount++;
|
|
973
|
+
const argSummary = _fmtToolArgs(tc.name, tc.args);
|
|
974
|
+
const toolIcon = _toolIcon(tc.name);
|
|
975
|
+
this._log('');
|
|
976
|
+
this._log(
|
|
977
|
+
` {#004400-fg}┌─{/#004400-fg} ` +
|
|
978
|
+
`{#00aa44-fg}${toolIcon}{/#00aa44-fg} ` +
|
|
979
|
+
`{#00ff88-fg}{bold}${esc(tc.name)}{/bold}{/#00ff88-fg} ` +
|
|
980
|
+
`{#333333-fg}step ${this._toolStepCount}{/#333333-fg}`
|
|
981
|
+
);
|
|
982
|
+
if (argSummary) {
|
|
983
|
+
this._log(` {#004400-fg}│{/#004400-fg} {#555555-fg}${esc(argSummary)}{/#555555-fg}`);
|
|
984
|
+
}
|
|
985
|
+
this._stopSpinner();
|
|
986
|
+
this.screen.render();
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
beforeToolCall: async (tc) => {
|
|
990
|
+
if (!NEEDS_APPROVAL.has(tc.name)) return 'yes';
|
|
991
|
+
|
|
992
|
+
const key = getAllowKey(tc);
|
|
993
|
+
if (this._alwaysAllowed.has(key)) return 'yes';
|
|
994
|
+
|
|
995
|
+
this._menuOpen = true;
|
|
996
|
+
const decision = await showApproval(this.screen, tc, this._lastToolContext);
|
|
997
|
+
this._menuOpen = false;
|
|
998
|
+
this.inputBox.focus();
|
|
999
|
+
|
|
1000
|
+
if (decision === 'always') {
|
|
1001
|
+
this._alwaysAllowed.add(key);
|
|
1002
|
+
this._log(`{#336633-fg} ✓ always allowed: {#555555-fg}${esc(key)}{/#555555-fg}{/#336633-fg}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (decision === 'no') {
|
|
1005
|
+
this._log(`{yellow-fg} ⊘ denied: ${esc(tc.name)}{/yellow-fg}`);
|
|
1006
|
+
this.screen.render();
|
|
1007
|
+
} else {
|
|
1008
|
+
this._startSpinner(`${_toolIcon(tc.name)} ${esc(tc.name)}…`);
|
|
1009
|
+
}
|
|
1010
|
+
this.screen.render();
|
|
1011
|
+
return decision;
|
|
1012
|
+
},
|
|
1013
|
+
|
|
1014
|
+
onToolResult: (tc, result) => {
|
|
1015
|
+
const lines = result.split('\n').filter(Boolean);
|
|
1016
|
+
const header = (lines[0] || '').substring(0, 60);
|
|
1017
|
+
this._log(
|
|
1018
|
+
` {#004400-fg}└─{/#004400-fg} {#00aa44-fg}✓{/#00aa44-fg} {#444444-fg}${esc(header)}{/#444444-fg}`
|
|
1019
|
+
);
|
|
1020
|
+
this._startSpinner('thinking…');
|
|
1021
|
+
this.screen.render();
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
const msg = err.message || String(err);
|
|
1026
|
+
if (!msg.includes('aborted')) {
|
|
1027
|
+
this._log(` {red-fg}✗ ${esc(msg.substring(0, 80))}{/red-fg}`);
|
|
1028
|
+
}
|
|
1029
|
+
this.isProcessing = false;
|
|
1030
|
+
this._abortCtrl = null;
|
|
1031
|
+
this._stopSpinner();
|
|
1032
|
+
this.screen.render();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ── Markdown-ish formatter ────────────────────────────────────────────────
|
|
1037
|
+
|
|
1038
|
+
// Coalesce screen.render() calls — saves 10-100x render work during streaming
|
|
1039
|
+
_scheduleRender() {
|
|
1040
|
+
if (this._renderPending) return;
|
|
1041
|
+
this._renderPending = true;
|
|
1042
|
+
// Batch renders at ~30fps during streaming (saves CPU), 60fps otherwise
|
|
1043
|
+
const delay = this.isProcessing ? 33 : 16;
|
|
1044
|
+
setTimeout(() => {
|
|
1045
|
+
this._renderPending = false;
|
|
1046
|
+
try { this.screen.render(); } catch (_) {}
|
|
1047
|
+
}, delay);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
_formatLine(line) {
|
|
1051
|
+
if (line === undefined || line === null) return '';
|
|
1052
|
+
|
|
1053
|
+
// ── Code fence toggle — ``` opens/closes block ──
|
|
1054
|
+
if (/^\s*`{3}/.test(line)) {
|
|
1055
|
+
if (this._inCodeBlock) {
|
|
1056
|
+
// Closing fence
|
|
1057
|
+
this._inCodeBlock = false;
|
|
1058
|
+
const lang = this._codeLang;
|
|
1059
|
+
this._codeLang = '';
|
|
1060
|
+
return ` {#006633-fg}└${'─'.repeat(48)}${lang ? ' ' + lang + ' ' : ''}${'─'.repeat(Math.max(0, 6 - lang.length))}┘{/#006633-fg}`;
|
|
1061
|
+
} else {
|
|
1062
|
+
// Opening fence
|
|
1063
|
+
this._inCodeBlock = true;
|
|
1064
|
+
const lang = line.replace(/^\s*`{3}/, '').trim() || 'code';
|
|
1065
|
+
this._codeLang = lang;
|
|
1066
|
+
const pad = Math.max(0, 50 - lang.length - 4);
|
|
1067
|
+
return ` {#006633-fg}┌─ {bold}{#44ffaa-fg}${esc(lang)}{/#44ffaa-fg}{/bold} ${'─'.repeat(pad)}┐{/#006633-fg}`;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ── Inside a code block — render as monospace, no markdown parsing ──
|
|
1072
|
+
if (this._inCodeBlock) {
|
|
1073
|
+
return ` {#006633-fg}│{/#006633-fg} {#bbddbb-fg}${esc(line)}{/#bbddbb-fg}`;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Blank line
|
|
1077
|
+
if (!line.trim()) return '';
|
|
1078
|
+
|
|
1079
|
+
// ── Markdown outside code blocks ──
|
|
1080
|
+
// H1 / H2 / H3
|
|
1081
|
+
if (/^#{1,3} /.test(line)) {
|
|
1082
|
+
const level = line.match(/^(#{1,3})/)[1].length;
|
|
1083
|
+
const text = line.replace(/^#{1,3}\s+/, '');
|
|
1084
|
+
if (level === 1) return `\n {bold}{#00ffaa-fg}▎ ${esc(text)}{/#00ffaa-fg}{/bold}`;
|
|
1085
|
+
if (level === 2) return `\n {bold}{#44ffaa-fg}▸ ${esc(text)}{/#44ffaa-fg}{/bold}`;
|
|
1086
|
+
return ` {bold}{#88ffcc-fg}${esc(text)}{/#88ffcc-fg}{/bold}`;
|
|
1087
|
+
}
|
|
1088
|
+
// Bullet lists
|
|
1089
|
+
if (/^[-*] /.test(line)) {
|
|
1090
|
+
return ` {#00aa66-fg}•{/#00aa66-fg} ${this._inline(line.slice(2))}`;
|
|
1091
|
+
}
|
|
1092
|
+
// Numbered lists
|
|
1093
|
+
if (/^\d+\. /.test(line)) {
|
|
1094
|
+
const m = line.match(/^(\d+)\.\s+(.*)$/);
|
|
1095
|
+
if (m) return ` {#00cc88-fg}${m[1]}.{/#00cc88-fg} ${this._inline(m[2])}`;
|
|
1096
|
+
}
|
|
1097
|
+
// Blockquotes
|
|
1098
|
+
if (line.startsWith('> ')) {
|
|
1099
|
+
return ` {#005500-fg}│{/#005500-fg} {#888888-fg}${esc(line.slice(2))}{/#888888-fg}`;
|
|
1100
|
+
}
|
|
1101
|
+
// Horizontal rule
|
|
1102
|
+
if (/^-{3,}$/.test(line.trim())) {
|
|
1103
|
+
return ` {#003300-fg}${'─'.repeat(50)}{/#003300-fg}`;
|
|
1104
|
+
}
|
|
1105
|
+
// Regular paragraph — apply inline formatting (bold, inline-code)
|
|
1106
|
+
return ` {#ccffcc-fg}${this._inline(line)}{/#ccffcc-fg}`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Inline markdown: **bold**, `code`
|
|
1110
|
+
_inline(text) {
|
|
1111
|
+
let out = esc(text);
|
|
1112
|
+
// Inline code
|
|
1113
|
+
out = out.replace(/`([^`]+)`/g, '{#001a00-bg}{#00ffaa-fg} $1 {/#00ffaa-fg}{/#001a00-bg}');
|
|
1114
|
+
// Bold
|
|
1115
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '{bold}{#ffffff-fg}$1{/#ffffff-fg}{/bold}');
|
|
1116
|
+
return out;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── Slash commands ────────────────────────────────────────────────────────
|
|
1120
|
+
|
|
1121
|
+
_runSlashCommand(text) {
|
|
1122
|
+
const parts = text.slice(1).trim().split(/\s+/);
|
|
1123
|
+
const cmd = parts[0].toLowerCase();
|
|
1124
|
+
const args = parts.slice(1);
|
|
1125
|
+
|
|
1126
|
+
switch (cmd) {
|
|
1127
|
+
case 'clear': case 'cls':
|
|
1128
|
+
this.chat.setContent('');
|
|
1129
|
+
this.agent.clearHistory();
|
|
1130
|
+
this._log('{#444444-fg}[chat cleared — context reset]{/#444444-fg}');
|
|
1131
|
+
break;
|
|
1132
|
+
|
|
1133
|
+
case 'undo': {
|
|
1134
|
+
const { getLastEdit } = require('./tools');
|
|
1135
|
+
const last = getLastEdit();
|
|
1136
|
+
if (!last) {
|
|
1137
|
+
this._log('{#888888-fg} No edits to undo.{/#888888-fg}');
|
|
1138
|
+
} else {
|
|
1139
|
+
const relPath = path.relative(this.agent.cwd, last.path);
|
|
1140
|
+
if (last.before === null) {
|
|
1141
|
+
// File was created — delete it
|
|
1142
|
+
try {
|
|
1143
|
+
require('fs').unlinkSync(last.path);
|
|
1144
|
+
this._log(`{#00aa44-fg} ↩ Undone: deleted {bold}${esc(relPath)}{/bold} (was newly created){/#00aa44-fg}`);
|
|
1145
|
+
} catch (e) {
|
|
1146
|
+
this._log(`{red-fg} ✗ Could not undo: ${esc(e.message)}{/red-fg}`);
|
|
1147
|
+
}
|
|
1148
|
+
} else {
|
|
1149
|
+
// Restore previous content
|
|
1150
|
+
try {
|
|
1151
|
+
require('fs').writeFileSync(last.path, last.before, 'utf8');
|
|
1152
|
+
this._log(`{#00aa44-fg} ↩ Undone: restored {bold}${esc(relPath)}{/bold}{/#00aa44-fg}`);
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
this._log(`{red-fg} ✗ Could not undo: ${esc(e.message)}{/red-fg}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
this._log('');
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
case 'modified': case 'changes': {
|
|
1163
|
+
const { getModifiedFiles } = require('./tools');
|
|
1164
|
+
const files = getModifiedFiles();
|
|
1165
|
+
if (!files.length) {
|
|
1166
|
+
this._log('{#888888-fg} No files modified this session.{/#888888-fg}');
|
|
1167
|
+
} else {
|
|
1168
|
+
this._log('{#00aa44-fg} Modified files this session:{/#00aa44-fg}');
|
|
1169
|
+
for (const f of files) {
|
|
1170
|
+
this._log(` {#44ff88-fg}${esc(path.relative(this.agent.cwd, f))}{/#44ff88-fg}`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
this._log('');
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
case 'help':
|
|
1178
|
+
this._showHelp();
|
|
1179
|
+
break;
|
|
1180
|
+
|
|
1181
|
+
case 'logout': case 'signout': {
|
|
1182
|
+
const cfg2 = require('./config');
|
|
1183
|
+
cfg2.setApiKey('');
|
|
1184
|
+
this._log('');
|
|
1185
|
+
this._log('{yellow-fg}{bold} ⟐ Logged out{/bold}{/yellow-fg}');
|
|
1186
|
+
this._log('{#444444-fg} API key cleared. Use /login or /addkey to reconnect.{/#444444-fg}');
|
|
1187
|
+
this._log('');
|
|
1188
|
+
this._updateModeBar();
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
case 'login': case 'auth': {
|
|
1193
|
+
// Redirect to /addkey — no separate login flow
|
|
1194
|
+
this._log('');
|
|
1195
|
+
this._log('{#444444-fg} Use {#00ff88-fg}/addkey <paste-key>{/#00ff88-fg} to add a provider key.{/#444444-fg}');
|
|
1196
|
+
this._log('{#444444-fg} Get a free key: {cyan-fg}vibsecurity.com{/cyan-fg}{/#444444-fg}');
|
|
1197
|
+
this._log('');
|
|
1198
|
+
this.screen.render();
|
|
1199
|
+
break;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
case 'model': case 'models':
|
|
1203
|
+
if (!this._menuOpen) this._showModelPicker();
|
|
1204
|
+
break;
|
|
1205
|
+
|
|
1206
|
+
case 'mode':
|
|
1207
|
+
this.modeIndex = (this.modeIndex + 1) % MODES.length;
|
|
1208
|
+
this.agent.setMode(MODES[this.modeIndex].id);
|
|
1209
|
+
this._updateModeBar();
|
|
1210
|
+
break;
|
|
1211
|
+
|
|
1212
|
+
case 'cwd': case 'pwd':
|
|
1213
|
+
this._log(`{#00cc00-fg}cwd{/} {#88ff88-fg}${esc(this.agent.cwd)}{/#88ff88-fg}`);
|
|
1214
|
+
break;
|
|
1215
|
+
|
|
1216
|
+
case 'cd': {
|
|
1217
|
+
const dir = args.join(' ');
|
|
1218
|
+
if (!dir) { this._log('{red-fg}usage: /cd <path>{/red-fg}'); break; }
|
|
1219
|
+
const newDir = path.resolve(this.agent.cwd, dir);
|
|
1220
|
+
try {
|
|
1221
|
+
require('fs').accessSync(newDir);
|
|
1222
|
+
this.agent.setCwd(newDir);
|
|
1223
|
+
try { process.chdir(newDir); } catch (_) {}
|
|
1224
|
+
this._log(`{#00cc00-fg}[cwd → ${esc(newDir)}]{/}`);
|
|
1225
|
+
} catch (_) {
|
|
1226
|
+
this._log(`{red-fg}[not found: ${esc(newDir)}]{/red-fg}`);
|
|
1227
|
+
}
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
case 'history':
|
|
1232
|
+
if (!this.inputHistory.length) {
|
|
1233
|
+
this._log('{#444444-fg}(no history){/#444444-fg}');
|
|
1234
|
+
} else {
|
|
1235
|
+
this.inputHistory.forEach((h, i) => {
|
|
1236
|
+
this._log(` {#444444-fg}${i + 1}.{/#444444-fg} {#88ff88-fg}${esc(h)}{/#88ff88-fg}`);
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
break;
|
|
1240
|
+
|
|
1241
|
+
case 'key': case 'apikey': {
|
|
1242
|
+
const newKey = args.join(' ').trim();
|
|
1243
|
+
const applyKey = (k) => {
|
|
1244
|
+
k = k.trim();
|
|
1245
|
+
const type = this.providers.add(k); // also handles config.setApiKey for OR keys
|
|
1246
|
+
this._dailyLimitHit = false;
|
|
1247
|
+
this.providers.clearLimits();
|
|
1248
|
+
this._updateModeBar();
|
|
1249
|
+
const pname = (PROVIDERS[type] || {}).name || type;
|
|
1250
|
+
this._log(`{#00cc00-fg}[${pname} key saved · limits cleared · /test to verify]{/}`);
|
|
1251
|
+
this.screen.render();
|
|
1252
|
+
};
|
|
1253
|
+
if (!newKey) {
|
|
1254
|
+
this._promptInput('Enter API key (sk-or-v1-… · gsk_… · csk-… · AIza…):', applyKey);
|
|
1255
|
+
} else {
|
|
1256
|
+
applyKey(newKey);
|
|
1257
|
+
}
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
case 'addkey': case 'addprovider': {
|
|
1262
|
+
const newKey = args.join(' ').trim();
|
|
1263
|
+
const doAdd = (k) => {
|
|
1264
|
+
k = k.trim();
|
|
1265
|
+
if (!k) return;
|
|
1266
|
+
const type = this.providers.add(k);
|
|
1267
|
+
const pdef = PROVIDERS[type] || {};
|
|
1268
|
+
this._dailyLimitHit = false;
|
|
1269
|
+
this.providers.clearLimits();
|
|
1270
|
+
this._updateModeBar();
|
|
1271
|
+
this._log('');
|
|
1272
|
+
this._log(`{#00cc00-fg}[{bold}${esc(pdef.name || type)}{/bold} added!]{/}`);
|
|
1273
|
+
this._log(`{#444444-fg} ${esc(pdef.freeNote || '')} · auto-rotation enabled{/#444444-fg}`);
|
|
1274
|
+
if (this.providers.list().length > 1) {
|
|
1275
|
+
this._log(`{#444444-fg} Providers: ${this.providers.list().map(p => p.name).join(' → ')}{/#444444-fg}`);
|
|
1276
|
+
}
|
|
1277
|
+
this._log('');
|
|
1278
|
+
this.screen.render();
|
|
1279
|
+
};
|
|
1280
|
+
if (!newKey) {
|
|
1281
|
+
this._log('');
|
|
1282
|
+
this._log('{green-fg}{bold}Free provider API keys — no credit card needed:{/bold}{/green-fg}');
|
|
1283
|
+
this._log('');
|
|
1284
|
+
Object.values(PROVIDERS).forEach(def => {
|
|
1285
|
+
const has = this.providers.list().some(p => p.type === def.type);
|
|
1286
|
+
this._log(
|
|
1287
|
+
` {#44ff88-fg}${def.name.padEnd(12)}{/#44ff88-fg}` +
|
|
1288
|
+
`{#444444-fg}${def.freeNote.padEnd(22)}{/#444444-fg}` +
|
|
1289
|
+
`{cyan-fg}${def.getKey()}{/cyan-fg}` +
|
|
1290
|
+
(has ? ' {#336633-fg}✓ added{/#336633-fg}' : '')
|
|
1291
|
+
);
|
|
1292
|
+
});
|
|
1293
|
+
this._log('');
|
|
1294
|
+
this._log(' {#444444-fg}Then: /addkey <paste-key-here>{/#444444-fg}');
|
|
1295
|
+
this._log('');
|
|
1296
|
+
this.screen.render();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
doAdd(newKey);
|
|
1300
|
+
// Silently refresh models — auto-rotation handles the rest
|
|
1301
|
+
this._silentModelRefresh();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
case 'providers': case 'provider': {
|
|
1306
|
+
const list = this.providers.list();
|
|
1307
|
+
this._log('');
|
|
1308
|
+
this._log('{green-fg}{bold}Configured Providers:{/bold}{/green-fg}');
|
|
1309
|
+
this._log('');
|
|
1310
|
+
list.forEach((p, i) => {
|
|
1311
|
+
const active = p.active ? '{yellow-fg}▶{/yellow-fg}' : ' ';
|
|
1312
|
+
const limited = p.limited ? ' {red-fg}(rate-limited){/red-fg}' : '';
|
|
1313
|
+
const keyHint = p.apiKey ? `{#333333-fg}${p.apiKey.substring(0, 14)}…{/#333333-fg}` : '';
|
|
1314
|
+
this._log(
|
|
1315
|
+
` ${active} {#44ff88-fg}{bold}${esc(p.name || p.type)}{/bold}{/#44ff88-fg}` +
|
|
1316
|
+
` {#444444-fg}${esc(p.freeNote || '')} ${keyHint}{/#444444-fg}${limited}`
|
|
1317
|
+
);
|
|
1318
|
+
});
|
|
1319
|
+
this._log('');
|
|
1320
|
+
this._log('{#444444-fg} Add more: /addkey <key> · Remove: /rmprovider <name>{/#444444-fg}');
|
|
1321
|
+
this._log('');
|
|
1322
|
+
break;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
case 'rmprovider': case 'removeprovider': {
|
|
1326
|
+
const typeName = (args[0] || '').toLowerCase();
|
|
1327
|
+
const match = Object.keys(PROVIDERS).find(t => t.startsWith(typeName));
|
|
1328
|
+
if (!match || match === 'openrouter') {
|
|
1329
|
+
this._log('{red-fg}usage: /rmprovider <groq|cerebras|gemini|mistral>{/red-fg}');
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1332
|
+
this.providers.remove(match);
|
|
1333
|
+
this._updateModeBar();
|
|
1334
|
+
this._log(`{#00cc00-fg}[${PROVIDERS[match].name} removed]{/}`);
|
|
1335
|
+
break;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
case 'tokens': case 'maxtokens': {
|
|
1339
|
+
const cfg = require('./config');
|
|
1340
|
+
const n = parseInt(args[0], 10);
|
|
1341
|
+
if (!n || n < 128 || n > 200000) {
|
|
1342
|
+
this._log(
|
|
1343
|
+
`{#00cc00-fg}max_tokens{/} {#88ff88-fg}${cfg.maxTokens}{/#88ff88-fg} ` +
|
|
1344
|
+
`{#444444-fg}· usage: /tokens <128-200000> e.g. /tokens 2048{/#444444-fg}`
|
|
1345
|
+
);
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
cfg.maxTokens = n;
|
|
1349
|
+
// Persist
|
|
1350
|
+
const { saveConfig } = (() => {
|
|
1351
|
+
// inline save via config module's saveConfig (not exported, so re-do it)
|
|
1352
|
+
const os = require('os');
|
|
1353
|
+
const path = require('path');
|
|
1354
|
+
const fs = require('fs');
|
|
1355
|
+
const dir = path.join(os.homedir(), '.vibehacker');
|
|
1356
|
+
const file = path.join(dir, 'config.json');
|
|
1357
|
+
return {
|
|
1358
|
+
saveConfig: (data) => {
|
|
1359
|
+
try {
|
|
1360
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1361
|
+
let existing = {};
|
|
1362
|
+
try { existing = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) {}
|
|
1363
|
+
fs.writeFileSync(file, JSON.stringify({ ...existing, ...data }, null, 2));
|
|
1364
|
+
} catch (_) {}
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
})();
|
|
1368
|
+
saveConfig({ maxTokens: n });
|
|
1369
|
+
this._log(`{#00cc00-fg}[max_tokens → {#88ff88-fg}${n}{/#88ff88-fg}]{/} {#444444-fg}less tokens = fewer rate-limit hits{/#444444-fg}`);
|
|
1370
|
+
if (this._dailyLimitHit) {
|
|
1371
|
+
this._dailyLimitHit = false;
|
|
1372
|
+
this.providers.clearLimits();
|
|
1373
|
+
this._log('{#444444-fg}[rate-limit flags cleared — try your message again]{/#444444-fg}');
|
|
1374
|
+
}
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
case 'retry': {
|
|
1379
|
+
if (this.isProcessing || !this._lastMessage) {
|
|
1380
|
+
this._log('{#444444-fg}(nothing to retry){/#444444-fg}');
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
this._log('{#444444-fg}[retrying last message…]{/#444444-fg}');
|
|
1384
|
+
this.screen.render();
|
|
1385
|
+
this._submit(this._lastMessage);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
case 'approve': case 'trust': {
|
|
1390
|
+
// /approve list — show currently always-allowed patterns
|
|
1391
|
+
// /approve clear — clear all always-allowed patterns
|
|
1392
|
+
const sub = (args[0] || 'list').toLowerCase();
|
|
1393
|
+
if (sub === 'clear') {
|
|
1394
|
+
this._alwaysAllowed.clear();
|
|
1395
|
+
this._log('{#00cc00-fg}[all always-allowed rules cleared]{/}');
|
|
1396
|
+
} else {
|
|
1397
|
+
if (this._alwaysAllowed.size === 0) {
|
|
1398
|
+
this._log('{#444444-fg}(no always-allowed rules — approve with "Yes, always" during tool calls){/#444444-fg}');
|
|
1399
|
+
} else {
|
|
1400
|
+
this._log('{green-fg}{bold}Always-allowed tool patterns:{/bold}{/green-fg}');
|
|
1401
|
+
for (const k of this._alwaysAllowed) {
|
|
1402
|
+
this._log(` {#44ff88-fg}✓{/#44ff88-fg} {#888888-fg}${esc(k)}{/#888888-fg}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
case 'test':
|
|
1410
|
+
this._testApiKey();
|
|
1411
|
+
break;
|
|
1412
|
+
|
|
1413
|
+
case 'refresh':
|
|
1414
|
+
this._refreshModels();
|
|
1415
|
+
break;
|
|
1416
|
+
|
|
1417
|
+
case 'new-session': case 'newsession': case 'new': {
|
|
1418
|
+
this.chat.setContent('');
|
|
1419
|
+
this.agent.clearHistory();
|
|
1420
|
+
this._tokenCount = 0;
|
|
1421
|
+
this._toolStepCount = 0;
|
|
1422
|
+
this._log('');
|
|
1423
|
+
this._log('{#003300-fg} ══════════════════════════════════════════════{/#003300-fg}');
|
|
1424
|
+
this._log('');
|
|
1425
|
+
this._log('{green-fg}{bold} ⟐ New Session Started{/bold}{/green-fg}');
|
|
1426
|
+
this._log('{#444444-fg} Chat history and context cleared.{/#444444-fg}');
|
|
1427
|
+
this._log('');
|
|
1428
|
+
this._log('{#003300-fg} ══════════════════════════════════════════════{/#003300-fg}');
|
|
1429
|
+
this._log('');
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
case 'account': case 'acct': case 'profile': {
|
|
1434
|
+
const cfg3 = require('./config');
|
|
1435
|
+
this._log('');
|
|
1436
|
+
this._log('{#003300-fg} ══════════════════════════════════════════════{/#003300-fg}');
|
|
1437
|
+
this._log('{green-fg}{bold} ⟐ Account{/bold}{/green-fg}');
|
|
1438
|
+
this._log('{#003300-fg} ══════════════════════════════════════════════{/#003300-fg}');
|
|
1439
|
+
this._log('');
|
|
1440
|
+
if (!cfg3.hasKey()) {
|
|
1441
|
+
this._log('{#888888-fg} Not logged in.{/#888888-fg}');
|
|
1442
|
+
this._log('{#444444-fg} Use /login or /addkey to connect.{/#444444-fg}');
|
|
1443
|
+
} else {
|
|
1444
|
+
// Tier info
|
|
1445
|
+
const tierLabel = this._tier === 'pro'
|
|
1446
|
+
? '{#ffaa00-fg}{bold}PRO{/bold}{/#ffaa00-fg}'
|
|
1447
|
+
: this._tier === 'free'
|
|
1448
|
+
? '{#00cc44-fg}FREE{/#00cc44-fg}'
|
|
1449
|
+
: '{#888888-fg}unknown{/#888888-fg}';
|
|
1450
|
+
this._log(` {#00ff88-fg}Tier:{/#00ff88-fg} ${tierLabel}`);
|
|
1451
|
+
|
|
1452
|
+
// Usage
|
|
1453
|
+
if (this._remaining !== null && this._remaining !== undefined) {
|
|
1454
|
+
const limit = this._dailyLimit || 50;
|
|
1455
|
+
const used = limit - this._remaining;
|
|
1456
|
+
const pct = Math.round((used / limit) * 100);
|
|
1457
|
+
const barW = 20;
|
|
1458
|
+
const filled = Math.round((used / limit) * barW);
|
|
1459
|
+
const bar = '{#00ff88-fg}' + '█'.repeat(Math.min(filled, barW)) + '{/#00ff88-fg}' +
|
|
1460
|
+
'{#1a3a1a-fg}' + '░'.repeat(Math.max(0, barW - filled)) + '{/#1a3a1a-fg}';
|
|
1461
|
+
this._log(` {#00ff88-fg}Usage:{/#00ff88-fg} ${bar} ${used}/${limit} (${pct}%)`);
|
|
1462
|
+
this._log(` {#00ff88-fg}Remaining:{/#00ff88-fg} {#88ff88-fg}${this._remaining}{/#88ff88-fg} requests today`);
|
|
1463
|
+
} else {
|
|
1464
|
+
this._log(` {#00ff88-fg}Usage:{/#00ff88-fg} {#888888-fg}(checking…){/#888888-fg}`);
|
|
1465
|
+
// Trigger a check
|
|
1466
|
+
this._checkTier();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Provider info
|
|
1470
|
+
const provs = this.providers.list();
|
|
1471
|
+
if (provs.length > 0) {
|
|
1472
|
+
this._log(` {#00ff88-fg}Providers:{/#00ff88-fg} ${provs.map(p => `{#44ff88-fg}${esc(p.name)}{/#44ff88-fg}`).join(', ')}`);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (this._tier === 'free') {
|
|
1476
|
+
this._log('');
|
|
1477
|
+
this._log(' {#ffaa00-fg}Go Pro for unlimited requests:{/#ffaa00-fg}');
|
|
1478
|
+
this._log(' {cyan-fg}https://vibsecurity.com{/cyan-fg}');
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
this._log('');
|
|
1482
|
+
this._log('{#003300-fg} ══════════════════════════════════════════════{/#003300-fg}');
|
|
1483
|
+
this._log('');
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
case 'update': case 'upgrade-cli': {
|
|
1488
|
+
this._log('');
|
|
1489
|
+
this._log('{#00aa44-fg} ⟳ Checking for updates…{/#00aa44-fg}');
|
|
1490
|
+
this.screen.render();
|
|
1491
|
+
this._runUpdate();
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
case 'pro': case 'upgrade': case 'pricing': {
|
|
1496
|
+
const { openBrowser } = require('./auth');
|
|
1497
|
+
const url = 'https://vibsecurity.com/pricing';
|
|
1498
|
+
this._log('');
|
|
1499
|
+
this._log('{#ffaa00-fg}{bold} ⟐ Upgrade to Vibe Hacker Pro{/bold}{/#ffaa00-fg}');
|
|
1500
|
+
this._log(` {cyan-fg}${url}{/cyan-fg}`);
|
|
1501
|
+
this._log('{#444444-fg} Unlimited requests · priority models · no rate limits{/#444444-fg}');
|
|
1502
|
+
this._log('');
|
|
1503
|
+
openBrowser(url).catch(() => {});
|
|
1504
|
+
break;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
case 'exit': case 'quit':
|
|
1508
|
+
this._exit();
|
|
1509
|
+
return; // MUST return — screen is destroyed, do not call render() below
|
|
1510
|
+
|
|
1511
|
+
case '': // bare "/" or accidental submit
|
|
1512
|
+
if (!this._menuOpen) this._showSlashMenu();
|
|
1513
|
+
return;
|
|
1514
|
+
|
|
1515
|
+
default:
|
|
1516
|
+
this._log(`{red-fg}✗ unknown: /${esc(cmd)}{/red-fg} {#444444-fg}try /help{/#444444-fg}`);
|
|
1517
|
+
}
|
|
1518
|
+
this.screen.render();
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ── Overlays ──────────────────────────────────────────────────────────────
|
|
1522
|
+
|
|
1523
|
+
async _showSlashMenu() {
|
|
1524
|
+
const cfg = require('./config');
|
|
1525
|
+
const hasKey = cfg.hasKey();
|
|
1526
|
+
|
|
1527
|
+
const cmds = [
|
|
1528
|
+
...(!hasKey ? [
|
|
1529
|
+
{ label: ' {green-fg}▸ Login with Vibe Hacker{/green-fg} {#336633-fg}browser{/#336633-fg}', value: '/login' },
|
|
1530
|
+
{ label: ' {green-fg}▸ Paste API Key{/green-fg} {#336633-fg}manual{/#336633-fg}', value: '!pastekey' },
|
|
1531
|
+
{ label: ' {#003300-fg}───────────────────────────────────────{/#003300-fg}', value: null },
|
|
1532
|
+
] : []),
|
|
1533
|
+
{ label: ' {#00ff88-fg}◈{/#00ff88-fg} Switch Mode {#336633-fg}Tab{/#336633-fg}', value: '/mode' },
|
|
1534
|
+
{ label: ' {#00ff88-fg}↻{/#00ff88-fg} Retry Last Message {#336633-fg}Ctrl+R{/#336633-fg}', value: '/retry' },
|
|
1535
|
+
{ label: ' {#00ff88-fg}✕{/#00ff88-fg} Clear Chat & Context', value: '/clear' },
|
|
1536
|
+
{ label: ' {#003300-fg}───────────────────────────────────────{/#003300-fg}', value: null },
|
|
1537
|
+
{ label: ' {#00cc66-fg}+{/#00cc66-fg} Add Provider Key {#336633-fg}Groq/Gemini{/#336633-fg}', value: '!pastekey' },
|
|
1538
|
+
{ label: ' {#00cc66-fg}⊞{/#00cc66-fg} List Providers', value: '/providers'},
|
|
1539
|
+
{ label: ' {#003300-fg}───────────────────────────────────────{/#003300-fg}', value: null },
|
|
1540
|
+
{ label: ' {#00cc66-fg}⊕{/#00cc66-fg} New Session {#336633-fg}clear all{/#336633-fg}', value: '/new-session' },
|
|
1541
|
+
...(hasKey ? [
|
|
1542
|
+
{ label: ' {#00cc66-fg}⊡{/#00cc66-fg} Account & Usage', value: '/account' },
|
|
1543
|
+
{ label: ' {#ffaa00-fg}★{/#ffaa00-fg} Upgrade to Pro {#664400-fg}vibsecurity.com{/#664400-fg}', value: '/pro' },
|
|
1544
|
+
] : []),
|
|
1545
|
+
{ label: ' {#00cc66-fg}⟳{/#00cc66-fg} Check for Updates {#336633-fg}npm{/#336633-fg}', value: '/update' },
|
|
1546
|
+
{ label: ' {#003300-fg}───────────────────────────────────────{/#003300-fg}', value: null },
|
|
1547
|
+
{ label: ' {#888888-fg}▸{/#888888-fg} Set Max Tokens', value: '/tokens ' },
|
|
1548
|
+
{ label: ' {#888888-fg}▸{/#888888-fg} Change Directory', value: '/cd ' },
|
|
1549
|
+
{ label: ' {#888888-fg}?{/#888888-fg} Help', value: '/help' },
|
|
1550
|
+
...(hasKey ? [
|
|
1551
|
+
{ label: ' {#888888-fg}↳{/#888888-fg} Re-login / Switch Account', value: '/login' },
|
|
1552
|
+
{ label: ' {#666666-fg}⏻{/#666666-fg} Logout', value: '/logout' },
|
|
1553
|
+
] : []),
|
|
1554
|
+
{ label: ' {red-fg}✕{/red-fg} Exit Vibe Hacker', value: '/exit' },
|
|
1555
|
+
];
|
|
1556
|
+
|
|
1557
|
+
const idx = await this._openList({
|
|
1558
|
+
label: '{bold}⚡ Commands{/bold}',
|
|
1559
|
+
items: cmds.map(c => c.label),
|
|
1560
|
+
selectedIndex: 0,
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
this._inputBuf = '';
|
|
1564
|
+
this._syncInputDisplay();
|
|
1565
|
+
|
|
1566
|
+
if (idx < 0 || !cmds[idx].value) return;
|
|
1567
|
+
|
|
1568
|
+
const val = cmds[idx].value;
|
|
1569
|
+
|
|
1570
|
+
// Special action: paste API key via input prompt
|
|
1571
|
+
if (val === '!pastekey') {
|
|
1572
|
+
this._promptInput('Paste API key (sk-or-v1-… / gsk_… / csk-… / AIza…):', (k) => {
|
|
1573
|
+
if (k && k.length >= 10) {
|
|
1574
|
+
this._runSlashCommand(`/addkey ${k}`);
|
|
1575
|
+
} else {
|
|
1576
|
+
this._log('{red-fg} Key too short — paste a valid API key{/red-fg}');
|
|
1577
|
+
this.screen.render();
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
if (val.endsWith(' ')) {
|
|
1584
|
+
this._inputBuf = val;
|
|
1585
|
+
this._syncInputDisplay();
|
|
1586
|
+
} else {
|
|
1587
|
+
this._runSlashCommand(val);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async _showModelPicker() {
|
|
1592
|
+
const curModel = this.providers.currentModel();
|
|
1593
|
+
const curId = (curModel || {}).id;
|
|
1594
|
+
const configured = new Set(this.providers.list().map(p => p.type));
|
|
1595
|
+
|
|
1596
|
+
// Build flat list: for each provider, a header row + its models.
|
|
1597
|
+
// Entries are: { kind: 'header'|'model', providerType, providerName, model?, configured }
|
|
1598
|
+
const entries = [];
|
|
1599
|
+
const providerOrder = ['vibehacker', 'openrouter', 'groq', 'cerebras', 'gemini', 'mistral', 'anthropic', 'openai', 'deepseek', 'xai', 'together'];
|
|
1600
|
+
|
|
1601
|
+
for (const type of providerOrder) {
|
|
1602
|
+
const def = PROVIDERS[type];
|
|
1603
|
+
if (!def) continue;
|
|
1604
|
+
let models = def.models || [];
|
|
1605
|
+
// For the active OpenRouter/Vibe Hacker provider, use live-refreshed models
|
|
1606
|
+
if ((type === 'openrouter' || type === 'vibehacker') && configured.has(type)) {
|
|
1607
|
+
const live = this.models.list();
|
|
1608
|
+
if (live && live.length) models = live;
|
|
1609
|
+
}
|
|
1610
|
+
if (!models.length) continue;
|
|
1611
|
+
const hasKey = configured.has(type);
|
|
1612
|
+
entries.push({ kind: 'header', providerType: type, providerName: def.name, hasKey });
|
|
1613
|
+
for (const m of models) {
|
|
1614
|
+
entries.push({ kind: 'model', providerType: type, providerName: def.name, model: m, hasKey });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Build display items
|
|
1619
|
+
let curIdx = 0;
|
|
1620
|
+
const items = entries.map((e, i) => {
|
|
1621
|
+
if (e.kind === 'header') {
|
|
1622
|
+
const status = e.hasKey
|
|
1623
|
+
? '{#00aa44-fg}● configured{/#00aa44-fg}'
|
|
1624
|
+
: '{#664400-fg}○ add key{/#664400-fg}';
|
|
1625
|
+
return `{#003300-fg}── {/#003300-fg}{bold}{#44ffaa-fg}${esc(e.providerName)}{/#44ffaa-fg}{/bold} {#003300-fg}──{/#003300-fg} ${status}`;
|
|
1626
|
+
}
|
|
1627
|
+
const m = e.model;
|
|
1628
|
+
const active = (m.id === curId && e.hasKey) ? '{#00ff88-fg}▸{/#00ff88-fg}' : ' ';
|
|
1629
|
+
if (m.id === curId && e.hasKey) curIdx = i;
|
|
1630
|
+
const freeBadge = m.free === true ? '{#00cc44-fg}FREE{/#00cc44-fg}'
|
|
1631
|
+
: m.free === false ? '{yellow-fg}PAID{/yellow-fg}'
|
|
1632
|
+
: '{#444444-fg} ? {/}';
|
|
1633
|
+
const ctxStr = fmtCtx(m.contextWindow);
|
|
1634
|
+
const nameStr = esc(m.name);
|
|
1635
|
+
const lockIcon = e.hasKey ? '' : ' {#664400-fg}🔒{/#664400-fg}';
|
|
1636
|
+
const nameColor = e.hasKey ? '{#88ff88-fg}' : '{#555555-fg}';
|
|
1637
|
+
return ` ${active} ${nameColor}${nameStr.padEnd(26)}{/} ${freeBadge} {#333333-fg}${ctxStr} ctx{/}${lockIcon}`;
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
// Disable header selection — skip them
|
|
1641
|
+
const idx = await this._openList({
|
|
1642
|
+
label: '{bold}⟐ All Models — Vibe Hacker · Anthropic · OpenAI · Groq · Gemini · More{/bold}',
|
|
1643
|
+
items,
|
|
1644
|
+
selectedIndex: curIdx,
|
|
1645
|
+
width: Math.min(78, (this.screen.width || 80) - 4),
|
|
1646
|
+
skipFn: (i) => entries[i] && entries[i].kind === 'header',
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
if (idx < 0) return;
|
|
1650
|
+
const chosen = entries[idx];
|
|
1651
|
+
if (!chosen || chosen.kind !== 'model') return;
|
|
1652
|
+
|
|
1653
|
+
// Provider not configured → prompt for key
|
|
1654
|
+
if (!chosen.hasKey) {
|
|
1655
|
+
const def = PROVIDERS[chosen.providerType] || {};
|
|
1656
|
+
this._log('');
|
|
1657
|
+
this._log(`{#ffaa00-fg} ${esc(chosen.providerName)} requires an API key{/#ffaa00-fg}`);
|
|
1658
|
+
this._log(`{#444444-fg} Get one at: ${esc(def.getKey ? def.getKey() : 'provider website')}{/#444444-fg}`);
|
|
1659
|
+
this._log('');
|
|
1660
|
+
this.screen.render();
|
|
1661
|
+
this._promptInput(`Paste your ${chosen.providerName} API key:`, (k) => {
|
|
1662
|
+
k = (k || '').trim();
|
|
1663
|
+
if (!k) return;
|
|
1664
|
+
try {
|
|
1665
|
+
this.providers.add(k);
|
|
1666
|
+
this.providers.selectByType(chosen.providerType);
|
|
1667
|
+
this.providers.selectModelById(chosen.model.id);
|
|
1668
|
+
this._pinnedModel = true;
|
|
1669
|
+
this._updateModeBar();
|
|
1670
|
+
this._log(`{green-fg} ✓ ${esc(chosen.providerName)} added — using ${esc(chosen.model.name)}{/green-fg}`);
|
|
1671
|
+
this.screen.render();
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
this._log(`{red-fg} ✗ ${esc(err.message || 'invalid key')}{/red-fg}`);
|
|
1674
|
+
this.screen.render();
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Provider already configured — switch to it and pick the model
|
|
1681
|
+
this.providers.selectByType(chosen.providerType);
|
|
1682
|
+
this.providers.selectModelById(chosen.model.id);
|
|
1683
|
+
this._pinnedModel = true;
|
|
1684
|
+
this._updateModeBar();
|
|
1685
|
+
this._log(`{#444444-fg} model → {#88ff88-fg}${esc(chosen.model.name)}{/#88ff88-fg} · ${esc(chosen.providerName)} · ${fmtCtx(chosen.model.contextWindow)} ctx · {#006600-fg}pinned{/#006600-fg}{/#444444-fg}`);
|
|
1686
|
+
this.screen.render();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async _showPalette() {
|
|
1690
|
+
const actions = [
|
|
1691
|
+
{ label: ' {#00ff88-fg}◈{/#00ff88-fg} Cycle Mode', fn: () => { this.modeIndex = (this.modeIndex + 1) % MODES.length; this.agent.setMode(MODES[this.modeIndex].id); this._updateModeBar(); } },
|
|
1692
|
+
{ label: ' {#00ff88-fg}✕{/#00ff88-fg} Clear Chat & Context', fn: () => this._runSlashCommand('/clear') },
|
|
1693
|
+
{ label: ' {#00ff88-fg}↩{/#00ff88-fg} Undo Last Edit', fn: () => this._runSlashCommand('/undo') },
|
|
1694
|
+
{ label: ' {#00ff88-fg}📄{/#00ff88-fg} Modified Files', fn: () => this._runSlashCommand('/modified') },
|
|
1695
|
+
{ label: ' {#888888-fg}?{/#888888-fg} Help', fn: () => this._showHelp() },
|
|
1696
|
+
{ label: ' {#888888-fg}▸{/#888888-fg} Change Directory', fn: () => this._promptInput('New directory:', (d) => this._runSlashCommand(`/cd ${d}`)) },
|
|
1697
|
+
{ label: ' {red-fg}✕{/red-fg} Exit', fn: () => this._exit() },
|
|
1698
|
+
];
|
|
1699
|
+
|
|
1700
|
+
const idx = await this._openList({
|
|
1701
|
+
label: '{bold}⚡ Palette{/bold}',
|
|
1702
|
+
items: actions.map(a => a.label),
|
|
1703
|
+
selectedIndex: 0,
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
if (idx >= 0 && actions[idx]) actions[idx].fn();
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
_promptInput(label, callback) {
|
|
1710
|
+
if (this._menuOpen) return;
|
|
1711
|
+
this._menuOpen = true;
|
|
1712
|
+
const sc = this.screen;
|
|
1713
|
+
|
|
1714
|
+
const prompt = blessed.prompt({
|
|
1715
|
+
parent: sc,
|
|
1716
|
+
top: 'center', left: 'center',
|
|
1717
|
+
width: 56, height: 8,
|
|
1718
|
+
tags: true,
|
|
1719
|
+
border: { type: 'line' },
|
|
1720
|
+
label: ` {#00ff88-fg}{bold}Input{/bold}{/#00ff88-fg} `,
|
|
1721
|
+
style: { fg: '#00ff88', bg: '#000a00', border: { fg: '#00aa44' } },
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
prompt.input(label, '', (err, value) => {
|
|
1725
|
+
this._menuOpen = false;
|
|
1726
|
+
prompt.destroy();
|
|
1727
|
+
this.inputBox.focus();
|
|
1728
|
+
sc.render();
|
|
1729
|
+
if (!err && value && value.trim()) callback(value.trim());
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
sc.render();
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
_showHelp() {
|
|
1736
|
+
const lines = [
|
|
1737
|
+
'',
|
|
1738
|
+
`{bold}{green-fg}◈ Vibe Hacker v${require('./config').version} — Command Reference{/green-fg}{/bold}`,
|
|
1739
|
+
'',
|
|
1740
|
+
'{cyan-fg}Keys{/cyan-fg}',
|
|
1741
|
+
' {#555555-fg}[Tab]{/#555555-fg} Cycle mode: Chat → Hunt',
|
|
1742
|
+
' {#555555-fg}[Ctrl+P]{/#555555-fg} Command palette',
|
|
1743
|
+
' {#555555-fg}[Ctrl+L]{/#555555-fg} Clear output',
|
|
1744
|
+
' {#555555-fg}[Ctrl+X]{/#555555-fg} Cancel request',
|
|
1745
|
+
' {#555555-fg}[Ctrl+R]{/#555555-fg} Retry last message',
|
|
1746
|
+
' {#555555-fg}[↑ / ↓]{/#555555-fg} Input history',
|
|
1747
|
+
' {#555555-fg}[PageUp/Dn]{/#555555-fg} Scroll output',
|
|
1748
|
+
' {#555555-fg}[Escape]{/#555555-fg} Clear input',
|
|
1749
|
+
' {#555555-fg}[Ctrl+C]{/#555555-fg} Exit',
|
|
1750
|
+
'',
|
|
1751
|
+
'{cyan-fg}Commands{/cyan-fg}',
|
|
1752
|
+
' {#00ff88-fg}/clear{/#00ff88-fg} Clear chat + context',
|
|
1753
|
+
' {#00ff88-fg}/undo{/#00ff88-fg} Undo last file edit',
|
|
1754
|
+
' {#00ff88-fg}/modified{/#00ff88-fg} Show files changed this session',
|
|
1755
|
+
' {#00ff88-fg}/mode{/#00ff88-fg} Cycle mode (Chat ↔ Hunt)',
|
|
1756
|
+
' {#00ff88-fg}/retry{/#00ff88-fg} Retry last message',
|
|
1757
|
+
' {#00ff88-fg}/cd <path>{/#00ff88-fg} Change working directory',
|
|
1758
|
+
' {#00ff88-fg}/cwd{/#00ff88-fg} Show current directory',
|
|
1759
|
+
' {#00ff88-fg}/addkey <key>{/#00ff88-fg} Add provider (Groq/Cerebras/Gemini)',
|
|
1760
|
+
' {#00ff88-fg}/providers{/#00ff88-fg} List providers',
|
|
1761
|
+
' {#00ff88-fg}/tokens <n>{/#00ff88-fg} Set max_tokens',
|
|
1762
|
+
' {#00ff88-fg}/approve{/#00ff88-fg} Manage auto-approved tools',
|
|
1763
|
+
' {#00ff88-fg}/update{/#00ff88-fg} Check for CLI updates',
|
|
1764
|
+
' {#00ff88-fg}/pro{/#00ff88-fg} Upgrade to Pro',
|
|
1765
|
+
' {#00ff88-fg}/help{/#00ff88-fg} This help',
|
|
1766
|
+
' {#00ff88-fg}/exit{/#00ff88-fg} Exit',
|
|
1767
|
+
'',
|
|
1768
|
+
'{cyan-fg}Free Providers (add with /addkey){/cyan-fg}',
|
|
1769
|
+
' {#44ff88-fg}Groq{/#44ff88-fg} 14,400 req/day console.groq.com',
|
|
1770
|
+
' {#44ff88-fg}Cerebras{/#44ff88-fg} 1M tokens/day inference.cerebras.ai',
|
|
1771
|
+
' {#44ff88-fg}Gemini{/#44ff88-fg} 1000 req/day aistudio.google.com',
|
|
1772
|
+
' {#44ff88-fg}Mistral{/#44ff88-fg} ~1B tok/month console.mistral.ai',
|
|
1773
|
+
'',
|
|
1774
|
+
'{cyan-fg}Modes{/cyan-fg}',
|
|
1775
|
+
' {bold}Hunt{/bold} Autonomous agent — reads/writes/edits files, runs commands, grep/glob',
|
|
1776
|
+
' {bold}Chat{/bold} Expert Q&A — security, engineering, threat intel',
|
|
1777
|
+
'',
|
|
1778
|
+
'{cyan-fg}Hunt Mode Tools{/cyan-fg}',
|
|
1779
|
+
' read_file · edit_file · write_file · execute_command',
|
|
1780
|
+
' grep · glob · list_files · search_files · create_directory · delete_file',
|
|
1781
|
+
'',
|
|
1782
|
+
];
|
|
1783
|
+
lines.forEach(l => this._log(l));
|
|
1784
|
+
this.screen.render();
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// ── Smart model/provider rotation — completely silent ───────────────────
|
|
1788
|
+
|
|
1789
|
+
_findNextAvailable() {
|
|
1790
|
+
const curProv = this.providers.current();
|
|
1791
|
+
|
|
1792
|
+
// Step 1: Try other models on the SAME provider (skip if circuit breaker open)
|
|
1793
|
+
if (!isCircuitOpen(curProv.baseURL)) {
|
|
1794
|
+
if (curProv.type === 'openrouter') {
|
|
1795
|
+
const allModels = this.models.list();
|
|
1796
|
+
const untried = allModels.find(m => !this._triedModels.has(m.id));
|
|
1797
|
+
if (untried) {
|
|
1798
|
+
this.models.selectById(untried.id);
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
} else {
|
|
1802
|
+
const models = curProv.models || [];
|
|
1803
|
+
const untried = models.find(m => !this._triedModels.has(m.id));
|
|
1804
|
+
if (untried) {
|
|
1805
|
+
this.providers.selectModelById(untried.id);
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Step 2: Switch to next non-limited provider, preferring healthy ones
|
|
1812
|
+
const switched = this.providers.markRateLimited();
|
|
1813
|
+
if (switched) {
|
|
1814
|
+
const newProv = this.providers.current();
|
|
1815
|
+
|
|
1816
|
+
// If the new provider's circuit breaker is open, try to find another
|
|
1817
|
+
if (isCircuitOpen(newProv.baseURL)) {
|
|
1818
|
+
const allProvs = this.providers.list();
|
|
1819
|
+
for (let i = 0; i < allProvs.length; i++) {
|
|
1820
|
+
if (!isCircuitOpen(allProvs[i].baseURL) && allProvs[i].type !== curProv.type) {
|
|
1821
|
+
// Found a healthy provider — switch to it
|
|
1822
|
+
this.providers.markRateLimited(); // skip the circuit-broken one
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Check if the new provider's current model was already tried
|
|
1829
|
+
const newModel = this.providers.currentModel();
|
|
1830
|
+
if (newModel && this._triedModels.has(newModel.id)) {
|
|
1831
|
+
const activeProv = this.providers.current();
|
|
1832
|
+
const newModels = activeProv.type === 'openrouter' ? this.models.list() : (activeProv.models || []);
|
|
1833
|
+
const untried2 = newModels.find(m => !this._triedModels.has(m.id));
|
|
1834
|
+
if (untried2) {
|
|
1835
|
+
this.providers.selectModelById(untried2.id);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return false; // everything exhausted
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// ── Retry with currently selected model (after auto-switch) ─────────────
|
|
1845
|
+
// Reuses the shared _runAgent helper — no code duplication.
|
|
1846
|
+
|
|
1847
|
+
async _retryWithCurrentModel(text, xmlFilter, prevFlush) {
|
|
1848
|
+
// Remove the user message pushed by the failed run — agent.run will re-add it
|
|
1849
|
+
const hist = this.agent.history;
|
|
1850
|
+
while (hist.length && hist[hist.length - 1].role === 'user' &&
|
|
1851
|
+
hist[hist.length - 1].content === text) {
|
|
1852
|
+
hist.pop();
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (prevFlush) prevFlush();
|
|
1856
|
+
xmlFilter.reset();
|
|
1857
|
+
|
|
1858
|
+
await this._runAgent(text, xmlFilter);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// ── Daily-limit warning banner ────────────────────────────────────────────
|
|
1862
|
+
|
|
1863
|
+
_showDailyLimitWarning(msg, isDailyLimit) {
|
|
1864
|
+
const configured = new Set(this.providers.list().map(p => p.type));
|
|
1865
|
+
const tried = this._triedModels.size;
|
|
1866
|
+
|
|
1867
|
+
this._log('');
|
|
1868
|
+
this._log('{#005500-fg} ┌──────────────────────────────────────────────────────┐{/#005500-fg}');
|
|
1869
|
+
this._log(`{#005500-fg} │{/#005500-fg} {yellow-fg}{bold}⚠ Daily limit reached{/bold}{/yellow-fg} {#005500-fg}│{/#005500-fg}`);
|
|
1870
|
+
this._log('{#005500-fg} │{/#005500-fg} {#005500-fg}│{/#005500-fg}');
|
|
1871
|
+
this._log("{#005500-fg} │{/#005500-fg} {#888888-fg}You've used all 50 free requests for today.{/#888888-fg} {#005500-fg}│{/#005500-fg}");
|
|
1872
|
+
this._log('{#005500-fg} │{/#005500-fg} {#888888-fg}Resets in 24 hours or upgrade for unlimited.{/#888888-fg} {#005500-fg}│{/#005500-fg}');
|
|
1873
|
+
this._log('{#005500-fg} │{/#005500-fg} {#005500-fg}│{/#005500-fg}');
|
|
1874
|
+
this._log('{#005500-fg} │{/#005500-fg} {#00ff88-fg}{bold}Go Pro → vibsecurity.com{/bold}{/#00ff88-fg} {#005500-fg}│{/#005500-fg}');
|
|
1875
|
+
this._log('{#005500-fg} │{/#005500-fg} {#555555-fg}Unlimited requests · Priority models{/#555555-fg} {#005500-fg}│{/#005500-fg}');
|
|
1876
|
+
this._log('{#005500-fg} │{/#005500-fg} {#005500-fg}│{/#005500-fg}');
|
|
1877
|
+
this._log('{#005500-fg} │{/#005500-fg} {#00aa44-fg}/addkey <key>{/#00aa44-fg} {#555555-fg}add your own provider key{/#555555-fg} {#005500-fg}│{/#005500-fg}');
|
|
1878
|
+
this._log('{#005500-fg} └──────────────────────────────────────────────────────┘{/#005500-fg}');
|
|
1879
|
+
this._log('');
|
|
1880
|
+
this._dailyLimitHit = true;
|
|
1881
|
+
this.screen.render();
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ── Silent background key check ──────────────────────────────────────────
|
|
1885
|
+
async _silentKeyCheck() {
|
|
1886
|
+
try {
|
|
1887
|
+
const cfg = require('./config');
|
|
1888
|
+
if (!cfg.apiKey) return; // no key yet, skip
|
|
1889
|
+
|
|
1890
|
+
// Use the proper checkKey function instead of raw request
|
|
1891
|
+
const { checkKey } = require('./api');
|
|
1892
|
+
const result = await checkKey(cfg.apiKey);
|
|
1893
|
+
|
|
1894
|
+
if (!result.ok && (result.status === 401 || result.status === 403)) {
|
|
1895
|
+
this._log('{red-fg}⚠ API key invalid — run {cyan-fg}/key{/cyan-fg} to update{/red-fg}');
|
|
1896
|
+
this.screen.render();
|
|
1897
|
+
}
|
|
1898
|
+
// Otherwise stay silent — key is valid or provider doesn't have a check endpoint
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
// Network error on startup — ignore silently
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// ── API helpers ───────────────────────────────────────────────────────────
|
|
1905
|
+
|
|
1906
|
+
async _testApiKey() {
|
|
1907
|
+
const prov = this.providers.current();
|
|
1908
|
+
const model = this.providers.currentModel();
|
|
1909
|
+
this._log(`{#444444-fg}[testing ${prov.name} key with ${model ? model.name : '?'}…]{/#444444-fg}`);
|
|
1910
|
+
this.screen.render();
|
|
1911
|
+
try {
|
|
1912
|
+
const axios = require('axios');
|
|
1913
|
+
const cfg = require('./config');
|
|
1914
|
+
const r = await axios.post(`${cfg.baseURL}/chat/completions`, {
|
|
1915
|
+
model: model ? model.id : '',
|
|
1916
|
+
messages: [{ role: 'user', content: 'Reply with one word: OK' }],
|
|
1917
|
+
max_tokens: 5,
|
|
1918
|
+
}, {
|
|
1919
|
+
headers: {
|
|
1920
|
+
'Authorization': `Bearer ${cfg.apiKey}`,
|
|
1921
|
+
'Content-Type': 'application/json',
|
|
1922
|
+
'HTTP-Referer': cfg.httpReferer,
|
|
1923
|
+
'X-Title': cfg.xTitle,
|
|
1924
|
+
},
|
|
1925
|
+
timeout: 20000,
|
|
1926
|
+
});
|
|
1927
|
+
const reply = r.data?.choices?.[0]?.message?.content?.trim() || '(empty)';
|
|
1928
|
+
this._log(`{green-fg}[${prov.name} OK — ${model ? model.name : '?'} responded: "${esc(reply)}"]{/green-fg}`);
|
|
1929
|
+
} catch (err) {
|
|
1930
|
+
const status = err.response?.status;
|
|
1931
|
+
const msg = err.response?.data?.error?.message || err.message;
|
|
1932
|
+
if (status === 401) {
|
|
1933
|
+
this._log(`{red-fg}[401 invalid key: ${esc(msg)}]{/red-fg}`);
|
|
1934
|
+
this._log('{yellow-fg} → /key sk-or-v1-YOUR_KEY to update{/yellow-fg}');
|
|
1935
|
+
} else if (status === 429) {
|
|
1936
|
+
this._log(`{yellow-fg}[429 rate limited — try another model]{/yellow-fg}`);
|
|
1937
|
+
} else if (status === 404) {
|
|
1938
|
+
this._log(`{yellow-fg}[404 model unavailable — run /refresh or /model]{/yellow-fg}`);
|
|
1939
|
+
} else {
|
|
1940
|
+
this._log(`{red-fg}[API error ${status || '?'}: ${esc(msg)}]{/red-fg}`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
this.screen.render();
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
async _refreshModels() {
|
|
1947
|
+
this._log('{#444444-fg}[refreshing model list…]{/#444444-fg}');
|
|
1948
|
+
this.screen.render();
|
|
1949
|
+
try {
|
|
1950
|
+
const cfg = require('./config');
|
|
1951
|
+
const count = await this.models.refresh(cfg.apiKey, cfg.baseURL);
|
|
1952
|
+
if (count > 0) {
|
|
1953
|
+
this._log(`{green-fg}[refreshed — ${count} free models available]{/green-fg}`);
|
|
1954
|
+
this._updateModeBar();
|
|
1955
|
+
} else {
|
|
1956
|
+
this._log('{yellow-fg}[no new models found — using built-in list]{/yellow-fg}');
|
|
1957
|
+
}
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
this._log(`{red-fg}[refresh failed: ${esc(err.message)}]{/red-fg}`);
|
|
1960
|
+
}
|
|
1961
|
+
this.screen.render();
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// ── Tier check — fetch plan info from Supabase ─────────────────────────────
|
|
1965
|
+
async _checkTier() {
|
|
1966
|
+
try {
|
|
1967
|
+
const cfg = require('./config');
|
|
1968
|
+
const { checkApiKey } = require('./supabase');
|
|
1969
|
+
const info = await checkApiKey(cfg.apiKey);
|
|
1970
|
+
if (info && info.valid) {
|
|
1971
|
+
this._tier = info.tier;
|
|
1972
|
+
this._remaining = info.remaining;
|
|
1973
|
+
this._dailyLimit = info.daily_limit === -1 ? null : info.daily_limit;
|
|
1974
|
+
this._refreshHeader();
|
|
1975
|
+
}
|
|
1976
|
+
} catch (_) {}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// ── Use request — decrement counter, check limit ──────────────────────────
|
|
1980
|
+
async _useRequest() {
|
|
1981
|
+
try {
|
|
1982
|
+
const cfg = require('./config');
|
|
1983
|
+
const { useRequest } = require('./supabase');
|
|
1984
|
+
const result = await useRequest(cfg.apiKey);
|
|
1985
|
+
if (!result) return true; // network error — allow
|
|
1986
|
+
|
|
1987
|
+
if (!result.allowed && result.error === 'FREE_TIER_LIMIT') {
|
|
1988
|
+
this._remaining = 0;
|
|
1989
|
+
this._refreshHeader();
|
|
1990
|
+
this._log('');
|
|
1991
|
+
this._log('{red-fg}{bold} ⚠ Daily request limit reached{/bold}{/red-fg}');
|
|
1992
|
+
this._log(`{#888888-fg} Free tier: ${this._dailyLimit || 50} requests/day{/#888888-fg}`);
|
|
1993
|
+
this._log('');
|
|
1994
|
+
this._log('{#00ff88-fg} Go Pro for unlimited requests:{/#00ff88-fg}');
|
|
1995
|
+
this._log(' {cyan-fg}{bold}https://vibsecurity.com{/bold}{/cyan-fg}');
|
|
1996
|
+
this._log('');
|
|
1997
|
+
this.screen.render();
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Update remaining count
|
|
2002
|
+
if (result.remaining !== undefined) {
|
|
2003
|
+
this._remaining = result.remaining;
|
|
2004
|
+
this._tier = result.tier;
|
|
2005
|
+
this._refreshHeader();
|
|
2006
|
+
}
|
|
2007
|
+
return true;
|
|
2008
|
+
} catch (_) {
|
|
2009
|
+
return true; // network error — allow
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// ── Silent model refresh on startup ─────────────────────────────────────────
|
|
2014
|
+
async _silentModelRefresh() {
|
|
2015
|
+
try {
|
|
2016
|
+
const cfg = require('./config');
|
|
2017
|
+
const count = await this.models.refresh(cfg.apiKey, cfg.baseURL);
|
|
2018
|
+
if (count > 0) {
|
|
2019
|
+
this._updateModeBar();
|
|
2020
|
+
}
|
|
2021
|
+
} catch (_) {
|
|
2022
|
+
// Network error on startup — keep using built-in list
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ── Update checker — queries npm registry for latest vibehacker version ────
|
|
2027
|
+
async _checkForUpdate() {
|
|
2028
|
+
try {
|
|
2029
|
+
const https = require('https');
|
|
2030
|
+
const current = require('../package.json').version;
|
|
2031
|
+
const latest = await new Promise((resolve, reject) => {
|
|
2032
|
+
const req = https.get('https://registry.npmjs.org/vibehacker/latest', {
|
|
2033
|
+
headers: { 'Accept': 'application/json' },
|
|
2034
|
+
timeout: 4000,
|
|
2035
|
+
}, (res) => {
|
|
2036
|
+
let data = '';
|
|
2037
|
+
res.on('data', c => data += c);
|
|
2038
|
+
res.on('end', () => {
|
|
2039
|
+
try { resolve(JSON.parse(data).version); } catch (e) { reject(e); }
|
|
2040
|
+
});
|
|
2041
|
+
});
|
|
2042
|
+
req.on('error', reject);
|
|
2043
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
2044
|
+
});
|
|
2045
|
+
if (latest && this._isNewer(latest, current)) {
|
|
2046
|
+
this._updateAvailable = latest;
|
|
2047
|
+
this._log('');
|
|
2048
|
+
this._log(` {#ffaa00-fg}{bold}★ Update available:{/bold}{/#ffaa00-fg} {#888888-fg}v${current} → {/}{bold}{#00ff88-fg}v${latest}{/#00ff88-fg}{/bold}`);
|
|
2049
|
+
this._log(' {#444444-fg}Run {#00ff88-fg}/update{/#00ff88-fg} to upgrade automatically{/#444444-fg}');
|
|
2050
|
+
this._log('');
|
|
2051
|
+
this.screen.render();
|
|
2052
|
+
}
|
|
2053
|
+
} catch (_) { /* silent — network error or offline */ }
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
_isNewer(a, b) {
|
|
2057
|
+
const pa = a.split('.').map(n => parseInt(n, 10) || 0);
|
|
2058
|
+
const pb = b.split('.').map(n => parseInt(n, 10) || 0);
|
|
2059
|
+
for (let i = 0; i < 3; i++) {
|
|
2060
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
2061
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
2062
|
+
}
|
|
2063
|
+
return false;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// ── /update — run npm install -g vibehacker@latest ─────────────────────────
|
|
2067
|
+
_runUpdate() {
|
|
2068
|
+
const { spawn } = require('child_process');
|
|
2069
|
+
const current = require('../package.json').version;
|
|
2070
|
+
|
|
2071
|
+
// Check latest first
|
|
2072
|
+
const https = require('https');
|
|
2073
|
+
https.get('https://registry.npmjs.org/vibehacker/latest', {
|
|
2074
|
+
headers: { 'Accept': 'application/json' },
|
|
2075
|
+
timeout: 5000,
|
|
2076
|
+
}, (res) => {
|
|
2077
|
+
let data = '';
|
|
2078
|
+
res.on('data', c => data += c);
|
|
2079
|
+
res.on('end', () => {
|
|
2080
|
+
let latest;
|
|
2081
|
+
try { latest = JSON.parse(data).version; } catch (_) {
|
|
2082
|
+
this._log('{red-fg} ✗ Could not reach npm registry{/red-fg}');
|
|
2083
|
+
this.screen.render();
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (!this._isNewer(latest, current)) {
|
|
2087
|
+
this._log(`{green-fg} ✓ Already up to date (v${current}){/green-fg}`);
|
|
2088
|
+
this._log('');
|
|
2089
|
+
this.screen.render();
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
this._log(`{#00ff88-fg} Found v${latest} — installing…{/#00ff88-fg}`);
|
|
2093
|
+
this._log('{#444444-fg} Running: npm install -g vibehacker@latest{/#444444-fg}');
|
|
2094
|
+
this.screen.render();
|
|
2095
|
+
|
|
2096
|
+
const isWin = process.platform === 'win32';
|
|
2097
|
+
const proc = spawn(isWin ? 'npm.cmd' : 'npm', ['install', '-g', 'vibehacker@latest'], {
|
|
2098
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2099
|
+
windowsHide: true,
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
let errOut = '';
|
|
2103
|
+
proc.stderr.on('data', (d) => { errOut += d.toString(); });
|
|
2104
|
+
proc.on('error', (err) => {
|
|
2105
|
+
this._log(`{red-fg} ✗ Update failed: ${esc(err.message)}{/red-fg}`);
|
|
2106
|
+
this._log('{#444444-fg} Try manually: npm install -g vibehacker@latest{/#444444-fg}');
|
|
2107
|
+
this.screen.render();
|
|
2108
|
+
});
|
|
2109
|
+
proc.on('close', (code) => {
|
|
2110
|
+
if (code === 0) {
|
|
2111
|
+
this._log('');
|
|
2112
|
+
this._log(`{green-fg}{bold} ✓ Updated to v${latest}{/bold}{/green-fg}`);
|
|
2113
|
+
this._log('{#444444-fg} Restart Vibe Hacker to use the new version.{/#444444-fg}');
|
|
2114
|
+
this._log('');
|
|
2115
|
+
} else {
|
|
2116
|
+
this._log(`{red-fg} ✗ npm exited with code ${code}{/red-fg}`);
|
|
2117
|
+
if (errOut.includes('EACCES') || errOut.includes('permission')) {
|
|
2118
|
+
this._log('{#444444-fg} Permission denied — try: sudo npm install -g vibehacker@latest{/#444444-fg}');
|
|
2119
|
+
} else {
|
|
2120
|
+
this._log('{#444444-fg} Try manually: npm install -g vibehacker@latest{/#444444-fg}');
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
this.screen.render();
|
|
2124
|
+
});
|
|
2125
|
+
});
|
|
2126
|
+
}).on('error', () => {
|
|
2127
|
+
this._log('{red-fg} ✗ Could not reach npm registry (offline?){/red-fg}');
|
|
2128
|
+
this.screen.render();
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// (_refreshModelsAndPick removed — models refresh silently, auto-rotation handles switching)
|
|
2133
|
+
|
|
2134
|
+
// ── Exit ──────────────────────────────────────────────────────────────────
|
|
2135
|
+
|
|
2136
|
+
_exit() {
|
|
2137
|
+
if (this._exiting) return; // prevent double-call
|
|
2138
|
+
this._exiting = true;
|
|
2139
|
+
clearInterval(this._spinnerTimer);
|
|
2140
|
+
clearInterval(this._petTimer);
|
|
2141
|
+
if (this._abortCtrl) { try { this._abortCtrl.abort(); } catch (_) {} }
|
|
2142
|
+
try { this.screen.destroy(); } catch (_) {}
|
|
2143
|
+
// Restore terminal state then exit
|
|
2144
|
+
process.stdout.write('\x1b[?25h\x1b[0m\r\n');
|
|
2145
|
+
process.stderr.write('\r\n \x1b[32m◈ Vibe Hacker\x1b[0m — session terminated.\r\n\r\n');
|
|
2146
|
+
// Use setImmediate so stdout flushes before exit
|
|
2147
|
+
setImmediate(() => process.exit(0));
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
module.exports = { HackerCLIApp };
|