blyuecode 0.1.12 → 0.1.14

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.
@@ -25,7 +25,7 @@ export class Agent {
25
25
  }
26
26
  persist() {
27
27
  try {
28
- saveSession(this.messages.slice(1)); // skip system prompt
28
+ saveSession(this.messages.slice(1));
29
29
  }
30
30
  catch { }
31
31
  }
@@ -41,25 +41,34 @@ export class Agent {
41
41
  this.persist();
42
42
  while (true) {
43
43
  const tools = getToolDefs();
44
- const res = await this.getProvider().chat(this.messages, tools);
45
- if (res.content) {
46
- yield res.content;
44
+ let content = '';
45
+ let toolCalls = [];
46
+ for await (const ev of this.getProvider().chatStream(this.messages, tools)) {
47
+ if (ev.type === 'delta') {
48
+ content += ev.text;
49
+ yield { type: 'text', delta: ev.text };
50
+ }
51
+ else if (ev.type === 'final') {
52
+ if (ev.content)
53
+ content = ev.content;
54
+ toolCalls = ev.toolCalls;
55
+ }
47
56
  }
48
- if (res.toolCalls.length === 0) {
57
+ if (toolCalls.length === 0) {
49
58
  this.persist();
50
59
  break;
51
60
  }
52
61
  this.messages.push({
53
62
  role: 'assistant',
54
- content: res.content,
55
- tool_calls: res.toolCalls.map((tc) => ({
63
+ content: content || null,
64
+ tool_calls: toolCalls.map((tc) => ({
56
65
  id: tc.id,
57
66
  type: 'function',
58
67
  function: { name: tc.name, arguments: tc.arguments },
59
68
  })),
60
69
  });
61
- for (const tc of res.toolCalls) {
62
- yield '\n[Calling ' + tc.name + '...]\n';
70
+ for (const tc of toolCalls) {
71
+ yield { type: 'tool_start', name: tc.name };
63
72
  let args = {};
64
73
  try {
65
74
  args = JSON.parse(tc.arguments || '{}');
@@ -72,9 +81,59 @@ export class Agent {
72
81
  content: result,
73
82
  });
74
83
  const preview = result.length > 500 ? result.slice(0, 500) + '...' : result;
75
- yield '[Result]: ' + preview + '\n';
84
+ yield { type: 'tool_end', name: tc.name, preview };
76
85
  }
77
86
  this.persist();
78
87
  }
79
88
  }
89
+ async compact() {
90
+ if (this.messages.length <= 4) {
91
+ throw new Error('Not enough history to compact (need > 3 messages).');
92
+ }
93
+ const before = this.messages.length;
94
+ const sys = this.messages[0];
95
+ const rest = this.messages.slice(1);
96
+ // Build a compact transcript
97
+ const transcript = rest
98
+ .map((m) => {
99
+ if (m.role === 'tool') {
100
+ return `[tool result] ${(m.content || '').slice(0, 600)}`;
101
+ }
102
+ if (m.role === 'assistant' && m.tool_calls) {
103
+ const calls = m.tool_calls.map((tc) => tc.function.name).join(', ');
104
+ return `[assistant → tools: ${calls}] ${m.content || ''}`;
105
+ }
106
+ return `[${m.role}] ${(m.content || '').slice(0, 1200)}`;
107
+ })
108
+ .join('\n\n');
109
+ const summaryPrompt = [
110
+ {
111
+ role: 'system',
112
+ content: 'Summarize the conversation history below for an AI coding agent. ' +
113
+ 'Include: what the user asked for, what files were touched, what tools were used, ' +
114
+ 'what was accomplished, and any pending tasks or important context. ' +
115
+ 'Be specific about filenames and outcomes. Aim for 300-600 words. ' +
116
+ 'Output only the summary, no preamble.',
117
+ },
118
+ { role: 'user', content: transcript },
119
+ ];
120
+ const summary = await this.getProvider().summarize(summaryPrompt);
121
+ this.messages = [
122
+ sys,
123
+ {
124
+ role: 'user',
125
+ content: `[Summary of prior conversation:\n${summary}\n]`,
126
+ },
127
+ {
128
+ role: 'assistant',
129
+ content: 'Understood. I have the context from our previous conversation.',
130
+ },
131
+ ];
132
+ this.persist();
133
+ return {
134
+ before,
135
+ after: this.messages.length,
136
+ summaryLen: summary.length,
137
+ };
138
+ }
80
139
  }
@@ -37,16 +37,81 @@ export class Provider {
37
37
  }
38
38
  catch { }
39
39
  }
40
- async chat(messages, tools) {
41
- const { messages: safeMessages, redactedCount } = this.sanitize(messages);
40
+ handleError(e) {
41
+ const status = e?.status || e?.response?.status;
42
+ const raw = e?.error?.message || e?.message || String(e);
43
+ if (status === 401) {
44
+ throw new Error(`401 Unauthorized — bad API key.\n Fix: rm ~/.blyue/.env then run again.`);
45
+ }
46
+ if (status === 404) {
47
+ throw new Error(`404 Model unavailable: "${this.model}".\n Fix: rm ~/.blyue/.env then pick a different model.`);
48
+ }
49
+ if (status === 429) {
50
+ throw new Error(`429 Rate limit / quota. Try again or switch model.`);
51
+ }
52
+ throw new Error(raw);
53
+ }
54
+ // Non-streaming call (used by /compact)
55
+ async chat(messages, tools = []) {
56
+ const { messages: safeMessages } = this.sanitize(messages);
42
57
  try {
43
58
  const res = await this.client.chat.completions.create({
59
+ model: this.model,
60
+ messages: safeMessages,
61
+ tools,
62
+ tool_choice: tools.length ? 'auto' : undefined,
63
+ });
64
+ const msg = res.choices[0].message;
65
+ return {
66
+ content: msg.content,
67
+ toolCalls: (msg.tool_calls || []).map((tc) => ({
68
+ id: tc.id,
69
+ name: tc.function.name,
70
+ arguments: tc.function.arguments,
71
+ })),
72
+ };
73
+ }
74
+ catch (e) {
75
+ this.handleError(e);
76
+ }
77
+ }
78
+ // Streaming call — yields deltas then a final summary
79
+ async *chatStream(messages, tools) {
80
+ const { messages: safeMessages, redactedCount } = this.sanitize(messages);
81
+ try {
82
+ const stream = await this.client.chat.completions.create({
44
83
  model: this.model,
45
84
  messages: safeMessages,
46
85
  tools,
47
86
  tool_choice: 'auto',
87
+ stream: true,
48
88
  });
49
- // Log debug info after successful call
89
+ let content = '';
90
+ const toolCallsMap = new Map();
91
+ for await (const chunk of stream) {
92
+ const delta = chunk.choices?.[0]?.delta;
93
+ if (!delta)
94
+ continue;
95
+ if (delta.content) {
96
+ content += delta.content;
97
+ yield { type: 'delta', text: delta.content };
98
+ }
99
+ if (delta.tool_calls) {
100
+ for (const tc of delta.tool_calls) {
101
+ const idx = tc.index ?? 0;
102
+ if (!toolCallsMap.has(idx)) {
103
+ toolCallsMap.set(idx, { id: '', name: '', args: '' });
104
+ }
105
+ const e = toolCallsMap.get(idx);
106
+ if (tc.id)
107
+ e.id = tc.id;
108
+ if (tc.function?.name)
109
+ e.name = tc.function.name;
110
+ if (tc.function?.arguments)
111
+ e.args += tc.function.arguments;
112
+ }
113
+ }
114
+ }
50
115
  const totalBytes = JSON.stringify(safeMessages).length;
51
116
  this.writeDebug({
52
117
  model: this.model,
@@ -57,30 +122,22 @@ export class Provider {
57
122
  messageCount: safeMessages.length,
58
123
  redactedCount,
59
124
  timestamp: new Date().toISOString(),
125
+ streaming: true,
60
126
  });
61
- const msg = res.choices[0].message;
62
- return {
63
- content: msg.content,
64
- toolCalls: (msg.tool_calls || []).map((tc) => ({
65
- id: tc.id,
66
- name: tc.function.name,
67
- arguments: tc.function.arguments,
68
- })),
69
- };
127
+ const toolCalls = [...toolCallsMap.values()].map((tc) => ({
128
+ id: tc.id,
129
+ name: tc.name,
130
+ arguments: tc.args,
131
+ }));
132
+ yield { type: 'final', content: content || null, toolCalls };
70
133
  }
71
134
  catch (e) {
72
- const status = e?.status || e?.response?.status;
73
- const raw = e?.error?.message || e?.message || String(e);
74
- if (status === 401) {
75
- throw new Error(`401 Unauthorized — bad API key.\n Fix: rm ~/.blyue/.env then run again.`);
76
- }
77
- if (status === 404) {
78
- throw new Error(`404 Model unavailable: "${this.model}".\n Fix: rm ~/.blyue/.env then pick a different model.`);
79
- }
80
- if (status === 429) {
81
- throw new Error(`429 Rate limit / quota. Try again or switch model.`);
82
- }
83
- throw new Error(raw);
135
+ this.handleError(e);
84
136
  }
85
137
  }
138
+ // Summarize helper for /compact
139
+ async summarize(messages) {
140
+ const res = await this.chat(messages, []);
141
+ return res.content || '';
142
+ }
86
143
  }
@@ -1,7 +1,9 @@
1
1
  import fs from 'fs/promises';
2
- import { existsSync, readFileSync } from 'fs';
3
- import { exec } from 'child_process';
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
3
+ import { exec, execSync } from 'child_process';
4
4
  import { promisify } from 'util';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
5
7
  import { hasN8n } from './config.js';
6
8
  import { permissions } from './permission.js';
7
9
  import { lineDiff, compactDiff } from './diff.js';
@@ -25,7 +27,7 @@ const BASE_TOOLS = [
25
27
  type: 'function',
26
28
  function: {
27
29
  name: 'write_file',
28
- description: 'Write content to a file (overwrites existing)',
30
+ description: 'Write content to a file (overwrites existing). Prefer apply_patch for edits to existing files.',
29
31
  parameters: {
30
32
  type: 'object',
31
33
  properties: {
@@ -36,6 +38,21 @@ const BASE_TOOLS = [
36
38
  },
37
39
  },
38
40
  },
41
+ {
42
+ type: 'function',
43
+ function: {
44
+ name: 'apply_patch',
45
+ description: 'Apply a unified diff patch to files. Use for targeted edits — cheaper and safer than rewriting whole files. ' +
46
+ 'Format: standard unified diff with --- a/path, +++ b/path, and @@ hunks.',
47
+ parameters: {
48
+ type: 'object',
49
+ properties: {
50
+ patch: { type: 'string', description: 'Unified diff patch content' },
51
+ },
52
+ required: ['patch'],
53
+ },
54
+ },
55
+ },
39
56
  {
40
57
  type: 'function',
41
58
  function: {
@@ -137,6 +154,57 @@ export async function executeTool(name, args) {
137
154
  await fs.writeFile(path, content, 'utf8');
138
155
  return 'Wrote ' + path;
139
156
  }
157
+ case 'apply_patch': {
158
+ const patchText = args.patch;
159
+ if (!patchText || !patchText.trim())
160
+ return 'Error: empty patch';
161
+ const lines = patchText.split('\n');
162
+ const files = [
163
+ ...new Set(lines
164
+ .filter((l) => l.startsWith('--- ') || l.startsWith('+++ '))
165
+ .map((l) => l.slice(4).trim())
166
+ .filter((f) => f !== '/dev/null')),
167
+ ];
168
+ if (!permissions.isAlwaysAllowed('apply_patch')) {
169
+ const diffPreview = lines.slice(0, 50).map((l) => {
170
+ if (l.startsWith('+') && !l.startsWith('+++'))
171
+ return { type: 'add', text: l.slice(1) };
172
+ if (l.startsWith('-') && !l.startsWith('---'))
173
+ return { type: 'remove', text: l.slice(1) };
174
+ return { type: 'same', text: l };
175
+ });
176
+ const answer = await permissions.ask({
177
+ tool: 'apply_patch',
178
+ title: 'Apply patch',
179
+ body: [
180
+ `Files: ${files.join(', ') || '(unknown)'}`,
181
+ `${lines.length} lines`,
182
+ ],
183
+ diff: diffPreview,
184
+ danger: 'medium',
185
+ });
186
+ if (answer === 'no')
187
+ return 'Denied by user: apply_patch';
188
+ }
189
+ const p = /^---\s+a\//m.test(patchText) ? '1' : '0';
190
+ const tmp = join(tmpdir(), `blyue-patch-${Date.now()}.diff`);
191
+ writeFileSync(tmp, patchText);
192
+ try {
193
+ const out = execSync(`patch -p${p} --no-backup-if-mismatch --forward < "${tmp}"`, { cwd: process.cwd(), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
194
+ return 'Patch applied.\n' + (out || '');
195
+ }
196
+ catch (e) {
197
+ const stderr = e.stderr?.toString() || '';
198
+ const stdout = e.stdout?.toString() || '';
199
+ throw new Error(`Patch failed.\nstdout: ${stdout}\nstderr: ${stderr}`);
200
+ }
201
+ finally {
202
+ try {
203
+ unlinkSync(tmp);
204
+ }
205
+ catch { }
206
+ }
207
+ }
140
208
  case 'list_files': {
141
209
  const dir = args.path || '.';
142
210
  const entries = await fs.readdir(dir, { withFileTypes: true });
package/dist/ui/Chat.js CHANGED
@@ -10,6 +10,7 @@ import { InputBar } from './InputBar.js';
10
10
  import { ModelPicker } from './ModelPicker.js';
11
11
  import { Settings } from './Settings.js';
12
12
  import { PermissionPrompt } from './PermissionPrompt.js';
13
+ import { Spinner } from './Spinner.js';
13
14
  import { theme } from './theme.js';
14
15
  let nextId = 0;
15
16
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
@@ -27,9 +28,8 @@ const HistoryLine = ({ item }) => {
27
28
  };
28
29
  const DEBUG_FILE = path.join(os.homedir(), '.blyue', 'last-request.json');
29
30
  function readDebug() {
30
- if (!fs.existsSync(DEBUG_FILE)) {
31
+ if (!fs.existsSync(DEBUG_FILE))
31
32
  return ['No request sent yet in this session.'];
32
- }
33
33
  try {
34
34
  const d = JSON.parse(fs.readFileSync(DEBUG_FILE, 'utf8'));
35
35
  const kb = (d.approxBytes / 1024).toFixed(1);
@@ -58,10 +58,12 @@ export const Chat = ({ resumedCount = 0 }) => {
58
58
  });
59
59
  const [input, setInput] = useState('');
60
60
  const [loading, setLoading] = useState(false);
61
+ const [streaming, setStreaming] = useState('');
61
62
  const [overlay, setOverlay] = useState('none');
62
63
  const [permReq, setPermReq] = useState(null);
63
64
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
64
65
  const [agent, setAgent] = useState(() => new Agent());
66
+ const [reaction, setReaction] = useState(null);
65
67
  useEffect(() => {
66
68
  const unsub = permissions.subscribe((req) => setPermReq(req));
67
69
  return unsub;
@@ -74,10 +76,16 @@ export const Chat = ({ resumedCount = 0 }) => {
74
76
  if (key.ctrl && inputChar === 's')
75
77
  setOverlay('settings');
76
78
  });
79
+ const flush = (kind, text) => {
80
+ if (!text)
81
+ return;
82
+ setHistory((h) => [...h, mkItem(kind, text)]);
83
+ };
77
84
  const handleSubmit = async (text) => {
78
85
  if (!text.trim() || loading)
79
86
  return;
80
87
  const trimmed = text.trim();
88
+ // ---- slash commands ----
81
89
  if (trimmed === '/model' || trimmed === '/models') {
82
90
  setInput('');
83
91
  setOverlay('model');
@@ -98,6 +106,23 @@ export const Chat = ({ resumedCount = 0 }) => {
98
106
  ]);
99
107
  return;
100
108
  }
109
+ if (trimmed === '/compact') {
110
+ setInput('');
111
+ setLoading(true);
112
+ setStreaming('');
113
+ try {
114
+ const r = await agent.compact();
115
+ setHistory((h) => [
116
+ ...h,
117
+ mkItem('tool', `Compacted: ${r.before} → ${r.after} messages (summary ${r.summaryLen} chars)`),
118
+ ]);
119
+ }
120
+ catch (e) {
121
+ setHistory((h) => [...h, mkItem('error', e.message)]);
122
+ }
123
+ setLoading(false);
124
+ return;
125
+ }
101
126
  if (trimmed === '/clear') {
102
127
  setInput('');
103
128
  agent.clear();
@@ -112,24 +137,55 @@ export const Chat = ({ resumedCount = 0 }) => {
112
137
  mkItem('tool', 'Commands:'),
113
138
  mkItem('tool', ' /model switch model (Ctrl+P)'),
114
139
  mkItem('tool', ' /settings open settings (Ctrl+S)'),
140
+ mkItem('tool', ' /compact summarize history to free context'),
115
141
  mkItem('tool', ' /debug show last request to provider'),
116
142
  mkItem('tool', ' /clear clear session and permissions'),
117
143
  mkItem('tool', ' /help show this list'),
118
144
  ]);
119
145
  return;
120
146
  }
147
+ // ---- normal run ----
121
148
  setInput('');
122
149
  setHistory((h) => [...h, mkItem('user', trimmed)]);
123
150
  setLoading(true);
151
+ setStreaming('');
152
+ // Jump animation on submit
153
+ setReaction('jump');
154
+ setTimeout(() => setReaction(null), 400);
155
+ let textBuf = '';
156
+ let toolBuf = '';
124
157
  try {
125
- for await (const chunk of agent.run(trimmed)) {
126
- const isTool = chunk.startsWith('\n[Calling') || chunk.startsWith('[Result]');
127
- setHistory((h) => [...h, mkItem(isTool ? 'tool' : 'ai', chunk)]);
158
+ for await (const ev of agent.run(trimmed)) {
159
+ if (ev.type === 'text') {
160
+ textBuf += ev.delta;
161
+ setStreaming(textBuf);
162
+ }
163
+ else if (ev.type === 'tool_start') {
164
+ if (textBuf) {
165
+ flush('ai', textBuf);
166
+ textBuf = '';
167
+ }
168
+ toolBuf = '\n[Calling ' + ev.name + '...]\n';
169
+ setStreaming(toolBuf);
170
+ }
171
+ else if (ev.type === 'tool_end') {
172
+ flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
173
+ toolBuf = '';
174
+ setStreaming('');
175
+ }
128
176
  }
177
+ if (textBuf)
178
+ flush('ai', textBuf);
179
+ // Success reaction
180
+ setReaction('success');
181
+ setTimeout(() => setReaction(null), 2000);
129
182
  }
130
183
  catch (e) {
131
184
  setHistory((h) => [...h, mkItem('error', e?.message || String(e))]);
185
+ setReaction('error');
186
+ setTimeout(() => setReaction(null), 3000);
132
187
  }
188
+ setStreaming('');
133
189
  setLoading(false);
134
190
  };
135
191
  const handleModelSelect = (id) => {
@@ -155,5 +211,5 @@ export const Chat = ({ resumedCount = 0 }) => {
155
211
  const shortModel = currentModel.length > maxModelLen
156
212
  ? '…' + currentModel.slice(-(maxModelLen - 1))
157
213
  : currentModel || 'unset';
158
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/debug" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
214
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), streaming ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.text, children: streaming }) })) : loading ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, {}) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading, reaction: reaction }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/compact" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
159
215
  };
@@ -4,9 +4,10 @@ import { Box, Text, useStdout } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
5
  import { Placeholder } from './Placeholder.js';
6
6
  import { RocketBar } from './RocketBar.js';
7
+ import { MascotPrompt } from './MascotPrompt.js';
7
8
  import { theme } from './theme.js';
8
9
  const DOTS = ['●○○○', '○●○○', '○○●○', '○○○●'];
9
- export const InputBar = ({ value, onChange, onSubmit, loading }) => {
10
+ export const InputBar = ({ value, onChange, onSubmit, loading, reaction }) => {
10
11
  const { stdout } = useStdout();
11
12
  const width = Math.max(24, stdout?.columns ?? 50);
12
13
  const [dotI, setDotI] = useState(0);
@@ -17,8 +18,11 @@ export const InputBar = ({ value, onChange, onSubmit, loading }) => {
17
18
  return () => clearInterval(int);
18
19
  }, [loading]);
19
20
  const hasText = value.length > 0;
21
+ // Decide mascot state, priority order:
22
+ // reaction > loading > typing > idle
23
+ const mascotState = reaction ?? (loading ? 'thinking' : hasText ? 'typing' : 'idle');
20
24
  const label = loading ? 'thinking' : 'blyue';
21
25
  const status = loading ? ' ' + DOTS[dotI] + ' ' : '';
22
26
  const botLen = Math.max(0, width - 2 - status.length);
23
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(RocketBar, { label: label, loading: loading, width: width }), _jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), hasText ? (_jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit })) : (_jsxs(_Fragment, { children: [_jsx(Placeholder, {}), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit })] }))] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(botLen) }), loading && _jsx(Text, { color: theme.accent, children: status }), _jsx(Text, { color: theme.muted, children: "\u256F" })] })] }));
27
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MascotPrompt, { state: mascotState }), _jsx(RocketBar, { label: label, loading: loading, width: width }), _jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), hasText ? (_jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit })) : (_jsxs(_Fragment, { children: [_jsx(Placeholder, {}), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit })] }))] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(botLen) }), loading && _jsx(Text, { color: theme.accent, children: status }), _jsx(Text, { color: theme.muted, children: "\u256F" })] })] }));
24
28
  };
@@ -1,3 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text } from 'ink';
3
- export const MakerMark = ({ color }) => (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: "\u25C6 " }), _jsx(Text, { color: color, bold: true, children: "L U M E N" })] }));
2
+ import { Box, Text } from 'ink';
3
+ import { Mascot } from './Mascot.js';
4
+ export const MakerMark = ({ color, narrow }) => {
5
+ // Phone / narrow: single line
6
+ if (narrow) {
7
+ return (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: "\u2584\u2588\u2584 " }), _jsx(Text, { color: color, bold: true, children: "L U M E N" })] }));
8
+ }
9
+ // Wide: mascot on left, wordmark vertically centered
10
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Mascot, { color: color }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, justifyContent: "center", children: [_jsx(Text, { children: " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: color, bold: true, children: "L U M E N" }), _jsx(Text, { children: " " }), _jsx(Text, { children: " " })] })] }));
11
+ };
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import os from 'os';
5
+ // ─────────────────────────────────────────────
6
+ // LUMEN mascot — gradient body, solid eyes
7
+ // Light at top fades to dark at bottom
8
+ // (shading = light emitting downward)
9
+ // ─────────────────────────────────────────────
10
+ const BODY_TOP = ' ▄▄▄▄▄▄▄ ';
11
+ const BODY_LIGHT = '░░░░░░░░░';
12
+ const BODY_FACE_A = '░ ● ● ░';
13
+ const BODY_FACE_B = '░ ⌣ ░';
14
+ const BODY_MED = '▒▒▒▒▒▒▒▒▒';
15
+ const BODY_DARK = '▓▓▓▓▓▓▓▓▓';
16
+ const BODY_BOT = ' ▀▀▀▀▀▀▀ ';
17
+ function shouldAnimate() {
18
+ if (process.env.BLYUE_ANIMATE === '1')
19
+ return true;
20
+ if (process.env.BLYUE_STATIC === '1')
21
+ return false;
22
+ try {
23
+ const gb = os.totalmem() / 1024 / 1024 / 1024;
24
+ if (gb < 8)
25
+ return false;
26
+ }
27
+ catch { }
28
+ return true;
29
+ }
30
+ // Full mascot — 9 wide × 6 tall
31
+ export const Mascot = ({ color }) => {
32
+ const [pulse, setPulse] = useState(0);
33
+ const [animate] = useState(() => shouldAnimate());
34
+ useEffect(() => {
35
+ if (!animate)
36
+ return;
37
+ const t = setInterval(() => setPulse((p) => (p + 1) % 3), 800);
38
+ return () => clearInterval(t);
39
+ }, [animate]);
40
+ // Subtle pulse: alternate shade intensity on the middle row
41
+ const mid = pulse === 0 ? BODY_MED : pulse === 1 ? '▒▒▒▒▒▒▒▒▒' : '░░▒░░▒░░';
42
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: color, bold: true, children: BODY_TOP }), _jsx(Text, { color: color, bold: true, children: BODY_LIGHT }), _jsx(Text, { color: color, bold: true, children: BODY_FACE_A }), _jsx(Text, { color: color, bold: true, children: BODY_FACE_B }), _jsx(Text, { color: color, bold: true, children: mid }), _jsx(Text, { color: color, bold: true, children: BODY_DARK }), _jsx(Text, { color: color, bold: true, children: BODY_BOT })] }));
43
+ };
44
+ // Compact — one line, for input bar & prompt
45
+ export const MascotInline = ({ color }) => (_jsx(Text, { color: color, bold: true, children: "\u2584 " }));
46
+ // Tiny — just the face, for very narrow terminals
47
+ export const MascotMini = ({ color }) => (_jsx(Text, { color: color, bold: true, children: "\u2584\u2588\u2584 " }));
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Text } from 'ink';
4
+ import { theme } from './theme.js';
5
+ // Face frames per state
6
+ const FACES = {
7
+ idle: ['◕‿◕', '◕‿◕', '◕_◕', '◕‿◕', '◕‿◕', '◕‿◕', '◕_◕', '◕‿◕'],
8
+ typing: ['◉‿◉', '◉‿◉', '◉‿◉', '◉_◉'],
9
+ thinking: ['◐_◐', '◓_◓', '◑_◑', '◒_◒'],
10
+ success: ['^‿^', '^▽^', '^‿^'],
11
+ error: ['◔︵◔', '◔︵◔', '◔_◔'],
12
+ jump: ['◕‿◕', '◉▽◉', '^▽^', '◉▽◉', '◕‿◕'],
13
+ };
14
+ // Frame speeds in ms
15
+ const SPEEDS = {
16
+ idle: 500,
17
+ typing: 700,
18
+ thinking: 120,
19
+ success: 300,
20
+ error: 400,
21
+ jump: 70,
22
+ };
23
+ export const MascotPrompt = ({ state }) => {
24
+ const frames = FACES[state];
25
+ const [i, setI] = useState(0);
26
+ useEffect(() => {
27
+ setI(0);
28
+ if (frames.length <= 1)
29
+ return;
30
+ const t = setInterval(() => setI((v) => (v + 1) % frames.length), SPEEDS[state]);
31
+ return () => clearInterval(t);
32
+ }, [state, frames.length]);
33
+ // Colors react to state too
34
+ const color = state === 'error' ? theme.error :
35
+ state === 'success' ? theme.success :
36
+ theme.accent;
37
+ return (_jsx(Text, { children: _jsx(Text, { color: color, bold: true, children: ' ' + frames[i] }) }));
38
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",