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.
@@ -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
+ };
@@ -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 === 'all' || tier === 'free')
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) => (t === 'all' ? 'free' : t === 'free' ? 'paid' : 'all'));
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' ? 'all' : tier === 'free' ? 'FREE only' : 'PAID only';
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
- return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.accent, bold: true, children: row.label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(sectionPad) + ' ' + row.count })] }, `h-${row.label}`));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.3.1",
3
+ "version": "0.3.5",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",