blyuecode 0.3.1 → 0.3.5
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/dist/core/agent.js +31 -0
- package/dist/core/modelUsage.js +51 -0
- package/dist/ui/Chat.js +18 -0
- package/dist/ui/McpPanel.js +50 -0
- package/dist/ui/ModelPicker.js +47 -9
- package/package.json +1 -1
package/dist/core/agent.js
CHANGED
|
@@ -36,9 +36,40 @@ export class Agent {
|
|
|
36
36
|
this.messages = [{ role: 'system', content: SYSTEM_PROMPT }];
|
|
37
37
|
clearSession();
|
|
38
38
|
}
|
|
39
|
+
estimateTokens() {
|
|
40
|
+
let chars = 0;
|
|
41
|
+
for (const m of this.messages) {
|
|
42
|
+
if (typeof m.content === 'string')
|
|
43
|
+
chars += m.content.length;
|
|
44
|
+
if (m.tool_calls)
|
|
45
|
+
chars += JSON.stringify(m.tool_calls).length;
|
|
46
|
+
}
|
|
47
|
+
return Math.ceil(chars / 4);
|
|
48
|
+
}
|
|
49
|
+
async maybeAutoCompact() {
|
|
50
|
+
if (process.env.BLYUE_NO_AUTO_COMPACT === '1')
|
|
51
|
+
return null;
|
|
52
|
+
const limit = Number(process.env.BLYUE_AUTO_COMPACT_TOKENS) || 80_000;
|
|
53
|
+
const est = this.estimateTokens();
|
|
54
|
+
if (est < limit)
|
|
55
|
+
return null;
|
|
56
|
+
if (this.messages.length <= 4)
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const r = await this.compact();
|
|
60
|
+
return { before: r.before, after: r.after };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
39
66
|
async *run(userInput) {
|
|
40
67
|
this.messages.push({ role: 'user', content: userInput });
|
|
41
68
|
this.persist();
|
|
69
|
+
const compacted = await this.maybeAutoCompact();
|
|
70
|
+
if (compacted) {
|
|
71
|
+
yield { type: 'compact', before: compacted.before, after: compacted.after };
|
|
72
|
+
}
|
|
42
73
|
while (true) {
|
|
43
74
|
const tools = getToolDefs();
|
|
44
75
|
let content = '';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Tracks which models the user has picked, so the picker can surface a
|
|
2
|
+
// RECENT section. Stored at ~/.blyue/model-usage.json. Bounded — one
|
|
3
|
+
// entry per unique model ever picked.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { join, dirname } from 'path';
|
|
7
|
+
const filePath = () => join(homedir(), '.blyue', 'model-usage.json');
|
|
8
|
+
let cache = null;
|
|
9
|
+
function load() {
|
|
10
|
+
if (cache)
|
|
11
|
+
return cache;
|
|
12
|
+
const p = filePath();
|
|
13
|
+
if (!existsSync(p)) {
|
|
14
|
+
cache = {};
|
|
15
|
+
return cache;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
cache = JSON.parse(readFileSync(p, 'utf8'));
|
|
19
|
+
return cache;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
cache = {};
|
|
23
|
+
return cache;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function save(m) {
|
|
27
|
+
const p = filePath();
|
|
28
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
29
|
+
writeFileSync(p, JSON.stringify(m, null, 2), 'utf8');
|
|
30
|
+
cache = m;
|
|
31
|
+
}
|
|
32
|
+
/** Call when the user picks a model (Ctrl+L or /model). */
|
|
33
|
+
export function recordModelUse(id) {
|
|
34
|
+
if (!id)
|
|
35
|
+
return;
|
|
36
|
+
const m = load();
|
|
37
|
+
const prev = m[id] ?? { count: 0, lastUsed: 0 };
|
|
38
|
+
m[id] = { count: prev.count + 1, lastUsed: Date.now() };
|
|
39
|
+
save(m);
|
|
40
|
+
}
|
|
41
|
+
/** Top N most-recently-used model IDs, newest first. */
|
|
42
|
+
export function getRecentModels(limit = 5) {
|
|
43
|
+
const m = load();
|
|
44
|
+
return Object.entries(m)
|
|
45
|
+
.sort(([, a], [, b]) => b.lastUsed - a.lastUsed)
|
|
46
|
+
.slice(0, limit)
|
|
47
|
+
.map(([id]) => id);
|
|
48
|
+
}
|
|
49
|
+
export function clearModelUsage() {
|
|
50
|
+
save({});
|
|
51
|
+
}
|
package/dist/ui/Chat.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Agent } from '../core/agent.js';
|
|
|
8
8
|
import { permissions } from '../core/permission.js';
|
|
9
9
|
import { InputBar } from './InputBar.js';
|
|
10
10
|
import { ModelPicker } from './ModelPicker.js';
|
|
11
|
+
import { McpPanel } from './McpPanel.js';
|
|
11
12
|
import { Settings } from './Settings.js';
|
|
12
13
|
import { PermissionPrompt } from './PermissionPrompt.js';
|
|
13
14
|
import { Spinner } from './Spinner.js';
|
|
@@ -17,6 +18,7 @@ import { pastelAt } from './pastel.js';
|
|
|
17
18
|
import { bumpActivity } from './activity.js';
|
|
18
19
|
import { theme } from './theme.js';
|
|
19
20
|
import { getConfig, saveConfig, applyConfig } from '../core/config.js';
|
|
21
|
+
import { recordModelUse } from '../core/modelUsage.js';
|
|
20
22
|
let nextId = 0;
|
|
21
23
|
const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
|
|
22
24
|
const HistoryLine = ({ item }) => {
|
|
@@ -127,6 +129,11 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
127
129
|
setOverlay('settings');
|
|
128
130
|
return;
|
|
129
131
|
}
|
|
132
|
+
if (trimmed === '/mcp') {
|
|
133
|
+
setInput('');
|
|
134
|
+
setOverlay('mcp');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
130
137
|
if (trimmed === '/debug') {
|
|
131
138
|
setInput('');
|
|
132
139
|
const lines = readDebug();
|
|
@@ -172,6 +179,7 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
172
179
|
mkItem('tool', 'Commands:'),
|
|
173
180
|
mkItem('tool', ' /model switch model (Ctrl+L)'),
|
|
174
181
|
mkItem('tool', ' /settings open settings (Ctrl+O)'),
|
|
182
|
+
mkItem('tool', ' /mcp show MCP servers and tools'),
|
|
175
183
|
mkItem('tool', ' /compact summarize history to free context'),
|
|
176
184
|
mkItem('tool', ' /debug show last request to provider'),
|
|
177
185
|
mkItem('tool', ' /clear clear session and permissions'),
|
|
@@ -206,6 +214,12 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
206
214
|
// React to the specific tool
|
|
207
215
|
setMascotState(toolToState(ev.name));
|
|
208
216
|
}
|
|
217
|
+
else if (ev.type === 'compact') {
|
|
218
|
+
setHistory((h) => [
|
|
219
|
+
...h,
|
|
220
|
+
mkItem('tool', `auto-compact: ${ev.before} → ${ev.after} messages`),
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
209
223
|
else if (ev.type === 'tool_end') {
|
|
210
224
|
flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
|
|
211
225
|
toolBuf = '';
|
|
@@ -227,6 +241,7 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
227
241
|
setMascotState(undefined);
|
|
228
242
|
};
|
|
229
243
|
const handleModelSelect = (id) => {
|
|
244
|
+
recordModelUse(id);
|
|
230
245
|
process.env.MODEL = id;
|
|
231
246
|
try {
|
|
232
247
|
const cfg = getConfig();
|
|
@@ -246,6 +261,9 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
246
261
|
if (overlay === 'model') {
|
|
247
262
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () => setOverlay('none') }) }));
|
|
248
263
|
}
|
|
264
|
+
if (overlay === 'mcp') {
|
|
265
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(McpPanel, { onClose: () => setOverlay('none') }) }));
|
|
266
|
+
}
|
|
249
267
|
if (overlay === 'settings') {
|
|
250
268
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onClose: () => setOverlay('none'), onModelChange: (m) => {
|
|
251
269
|
setCurrentModel(m);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { listMcpServers, initMcpRegistry, closeAllMcp, } from '../core/mcpRegistry.js';
|
|
5
|
+
import { mcpConfigPath } from '../core/mcp.js';
|
|
6
|
+
import { theme } from './theme.js';
|
|
7
|
+
export const McpPanel = ({ onClose }) => {
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const width = Math.max(40, stdout?.columns ?? 60);
|
|
10
|
+
const inner = width - 4;
|
|
11
|
+
const [servers, setServers] = useState(() => listMcpServers());
|
|
12
|
+
const [reloading, setReloading] = useState(false);
|
|
13
|
+
const [msg, setMsg] = useState(null);
|
|
14
|
+
// Refresh list on mount in case registry changed since last render.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setServers(listMcpServers());
|
|
17
|
+
}, []);
|
|
18
|
+
const reload = async () => {
|
|
19
|
+
setReloading(true);
|
|
20
|
+
setMsg(null);
|
|
21
|
+
try {
|
|
22
|
+
await closeAllMcp();
|
|
23
|
+
await initMcpRegistry();
|
|
24
|
+
setServers(listMcpServers());
|
|
25
|
+
setMsg('✓ reloaded');
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
setMsg('✗ ' + err.message);
|
|
29
|
+
}
|
|
30
|
+
setReloading(false);
|
|
31
|
+
};
|
|
32
|
+
useInput((input, key) => {
|
|
33
|
+
if (key.escape)
|
|
34
|
+
return onClose();
|
|
35
|
+
if (input === 'r' || input === 'R') {
|
|
36
|
+
if (!reloading)
|
|
37
|
+
void reload();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const label = 'mcp servers';
|
|
41
|
+
const topPad = Math.max(1, inner - label.length - 4);
|
|
42
|
+
const totalTools = servers.reduce((n, s) => n + s.toolCount, 0);
|
|
43
|
+
const healthy = servers.filter((s) => !s.error).length;
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["config: ", mcpConfigPath()] })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: [servers.length, " server", servers.length === 1 ? '' : 's', " \u00B7", ' '] }), _jsxs(Text, { color: healthy === servers.length ? theme.success : theme.warning, children: [healthy, " connected"] }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", totalTools, " tools"] })] }), _jsx(Text, { color: theme.muted, children: '│ ' + '─'.repeat(Math.max(1, inner - 2)) }), servers.length === 0 ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.warning, children: "no MCP servers configured" })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["add one to ", mcpConfigPath(), " then press [r]"] })] })] })) : (servers.map((s) => {
|
|
45
|
+
const status = s.error ? '✗' : '●';
|
|
46
|
+
const statusColor = s.error ? theme.error : theme.success;
|
|
47
|
+
const autoTag = s.autoAllow ? ' [auto]' : '';
|
|
48
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: statusColor, children: status + ' ' }), _jsx(Text, { color: theme.text, bold: true, children: s.name }), _jsxs(Text, { color: theme.muted, children: [autoTag, " \u00B7 ", s.toolCount, " tool", s.toolCount === 1 ? '' : 's'] })] }), s.error ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.error, children: s.error.slice(0, inner - 6) })] })) : (s.toolNames.slice(0, 4).map((t) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ · ' }), _jsx(Text, { color: theme.muted, children: t })] }, t)))), !s.error && s.toolNames.length > 4 && (_jsx(Text, { children: _jsx(Text, { color: theme.muted, children: '│ · +' + (s.toolNames.length - 4) + ' more' }) }))] }, s.name));
|
|
49
|
+
})), _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: " [" }), _jsx(Text, { color: theme.accent, children: "r" }), _jsx(Text, { color: theme.muted, children: "] reload \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "esc" }), _jsx(Text, { color: theme.muted, children: "] close" }), reloading && _jsx(Text, { color: theme.warning, children: " \u00B7 reloading\u2026" }), msg && _jsxs(Text, { color: theme.success, children: [" \u00B7 ", msg] })] })] }));
|
|
50
|
+
};
|
package/dist/ui/ModelPicker.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useEffect, useMemo } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
4
|
import { fetchModels, currentProviderLabel, } from '../core/models.js';
|
|
5
|
+
import { getRecentModels } from '../core/modelUsage.js';
|
|
5
6
|
import { theme } from './theme.js';
|
|
6
7
|
export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
7
8
|
const { stdout } = useStdout();
|
|
@@ -36,20 +37,33 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
36
37
|
cancelled = true;
|
|
37
38
|
};
|
|
38
39
|
}, [current]);
|
|
39
|
-
const { free, paid } = useMemo(() => {
|
|
40
|
+
const { recent, free, paid } = useMemo(() => {
|
|
40
41
|
const q = filter.toLowerCase();
|
|
41
42
|
const matched = q
|
|
42
43
|
? models.filter((m) => m.id.toLowerCase().includes(q))
|
|
43
44
|
: models;
|
|
45
|
+
const byId = new Map(matched.map((m) => [m.id, m]));
|
|
46
|
+
const recentIds = getRecentModels(5);
|
|
47
|
+
const recent = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const id of recentIds) {
|
|
50
|
+
const m = byId.get(id);
|
|
51
|
+
if (m && !seen.has(id)) {
|
|
52
|
+
recent.push(m);
|
|
53
|
+
seen.add(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
44
56
|
const free = [];
|
|
45
57
|
const paid = [];
|
|
46
58
|
for (const m of matched) {
|
|
59
|
+
if (seen.has(m.id))
|
|
60
|
+
continue; // already in recent
|
|
47
61
|
if (m.isFree)
|
|
48
62
|
free.push(m);
|
|
49
63
|
else
|
|
50
64
|
paid.push(m);
|
|
51
65
|
}
|
|
52
|
-
return { free, paid };
|
|
66
|
+
return { recent, free, paid };
|
|
53
67
|
}, [models, filter]);
|
|
54
68
|
const rows = useMemo(() => {
|
|
55
69
|
const out = [];
|
|
@@ -62,12 +76,23 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
62
76
|
out.push({ kind: 'model', model: m, absoluteIdx: abs++ });
|
|
63
77
|
}
|
|
64
78
|
};
|
|
65
|
-
if (tier === '
|
|
79
|
+
if (tier === 'recent') {
|
|
80
|
+
pushSection('RECENT', recent);
|
|
81
|
+
}
|
|
82
|
+
else if (tier === 'free') {
|
|
83
|
+
pushSection('FREE', free);
|
|
84
|
+
}
|
|
85
|
+
else if (tier === 'paid') {
|
|
86
|
+
pushSection('PAID', paid);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// 'all' — show recent first, then free, then paid
|
|
90
|
+
pushSection('RECENT', recent);
|
|
66
91
|
pushSection('FREE', free);
|
|
67
|
-
if (tier === 'all' || tier === 'paid')
|
|
68
92
|
pushSection('PAID', paid);
|
|
93
|
+
}
|
|
69
94
|
return out;
|
|
70
|
-
}, [free, paid, tier]);
|
|
95
|
+
}, [recent, free, paid, tier]);
|
|
71
96
|
const selectableIdxToRow = [];
|
|
72
97
|
rows.forEach((r, i) => {
|
|
73
98
|
if (r.kind === 'model')
|
|
@@ -87,7 +112,13 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
87
112
|
if (key.escape)
|
|
88
113
|
return onCancel();
|
|
89
114
|
if (key.tab) {
|
|
90
|
-
setTier((t) =>
|
|
115
|
+
setTier((t) => t === 'all'
|
|
116
|
+
? 'free'
|
|
117
|
+
: t === 'free'
|
|
118
|
+
? 'paid'
|
|
119
|
+
: t === 'paid'
|
|
120
|
+
? 'recent'
|
|
121
|
+
: 'all');
|
|
91
122
|
setIdx(0);
|
|
92
123
|
return;
|
|
93
124
|
}
|
|
@@ -116,7 +147,7 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
116
147
|
}
|
|
117
148
|
});
|
|
118
149
|
const inner = width - 4;
|
|
119
|
-
const total = free.length + paid.length;
|
|
150
|
+
const total = free.length + paid.length + recent.length;
|
|
120
151
|
const headerLabel = filter
|
|
121
152
|
? `filter: ${filter} · ${total} match`
|
|
122
153
|
: `${provider} · ${total} total · ${free.length} free`;
|
|
@@ -128,11 +159,18 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
|
|
|
128
159
|
const windowEnd = windowStart + maxVisible;
|
|
129
160
|
const visibleRows = rows.slice(windowStart, windowEnd);
|
|
130
161
|
const cursorAbs = idx;
|
|
131
|
-
const tierBadge = tier === 'all'
|
|
162
|
+
const tierBadge = tier === 'all'
|
|
163
|
+
? 'all'
|
|
164
|
+
: tier === 'free'
|
|
165
|
+
? 'FREE only'
|
|
166
|
+
: tier === 'paid'
|
|
167
|
+
? 'PAID only'
|
|
168
|
+
: 'RECENT only';
|
|
132
169
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: headerLabel }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["fetching ", provider, " catalog\u2026"] })] })) : error ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["\u2717 ", error.slice(0, inner - 6)] })] })) : rows.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["no matches", tier !== 'all' ? ` in ${tierBadge}` : ''] })] })) : (visibleRows.map((row, i) => {
|
|
133
170
|
if (row.kind === 'header') {
|
|
134
171
|
const sectionPad = Math.max(1, inner - row.label.length - row.count.toString().length - 5);
|
|
135
|
-
|
|
172
|
+
const headerColor = row.label === 'RECENT' ? theme.warning : theme.accent;
|
|
173
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: headerColor, bold: true, children: row.label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(sectionPad) + ' ' + row.count })] }, `h-${row.label}`));
|
|
136
174
|
}
|
|
137
175
|
const m = row.model;
|
|
138
176
|
const active = row.absoluteIdx === cursorAbs;
|