blyuecode 0.1.12 → 0.1.15

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/index.js CHANGED
@@ -23,7 +23,7 @@ const App = () => {
23
23
  setReady(true);
24
24
  }
25
25
  }, [consented]);
26
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}) }), !consented ? (_jsx(Consent, { onAccept: () => setConsented(true) })) : ready ? (_jsx(Chat, { resumedCount: resumedCount })) : (_jsx(Setup, { onDone: () => setReady(true) }))] }));
26
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}, "banner") }), !consented ? (_jsx(Consent, { onAccept: () => setConsented(true) })) : ready ? (_jsx(Chat, { resumedCount: resumedCount })) : (_jsx(Setup, { onDone: () => setReady(true) }))] }));
27
27
  };
28
28
  render(_jsx(App, {}), {
29
29
  patchConsole: false,
package/dist/ui/Banner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
4
  import { readFileSync } from 'fs';
@@ -63,6 +63,9 @@ const CODE_SMALL = [
63
63
  ];
64
64
  const BLYUE_WIDTH = 44;
65
65
  const BLYUE_SMALL_WIDTH = 27;
66
+ // Single <Text> — was previously a fragment with two <Text> children,
67
+ // which React/Ink flagged as "list without keys" under the parent Text.
68
+ const MakerMarkInline = ({ color }) => (_jsx(Text, { color: color, bold: true, children: "\u25C6 L U M E N" }));
66
69
  export const Banner = () => {
67
70
  const { stdout } = useStdout();
68
71
  const width = stdout?.columns ?? 80;
@@ -70,7 +73,6 @@ export const Banner = () => {
70
73
  const blyue = wide ? BLYUE_BIG : BLYUE_SMALL;
71
74
  const code = wide ? CODE_BIG : CODE_SMALL;
72
75
  const blockWidth = wide ? BLYUE_WIDTH : BLYUE_SMALL_WIDTH;
73
- // Left padding to center whole block in terminal
74
76
  const leftPad = Math.max(0, Math.floor((width - blockWidth) / 2));
75
77
  const pad = ' '.repeat(leftPad);
76
78
  const [animate] = useState(() => shouldAnimate());
@@ -86,5 +88,3 @@ export const Banner = () => {
86
88
  const version = getVersion();
87
89
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [pad, _jsx(MakerMarkInline, { color: brand })] }), _jsx(Text, { children: " " }), blyue.map((line, i) => (_jsx(Text, { color: hueAt((baseHue + i * 0.06) % 1), bold: true, children: pad + line }, 'b' + i))), code.map((line, i) => (_jsx(Text, { color: hueAt((baseHue + i * 0.06 + 0.35) % 1), bold: true, children: pad + line }, 'c' + i))), _jsx(Text, { children: " " }), _jsxs(Text, { children: [pad, _jsx(Text, { color: "gray", children: 'a coding agent · v' + version })] }), _jsx(Text, { children: " " })] }));
88
90
  };
89
- // Inline version of MakerMark so it aligns inside the padded Text
90
- const MakerMarkInline = ({ color }) => (_jsxs(_Fragment, { children: [_jsx(Text, { color: color, bold: true, children: "\u25C6 " }), _jsx(Text, { color: color, bold: true, children: "L U M E N" })] }));
package/dist/ui/Chat.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Text, useInput, useStdout, Static } from 'ink';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
@@ -10,6 +10,9 @@ 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';
14
+ import { toolToState } from './MascotPrompt.js';
15
+ import { bumpActivity } from './activity.js';
13
16
  import { theme } from './theme.js';
14
17
  let nextId = 0;
15
18
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
@@ -27,9 +30,8 @@ const HistoryLine = ({ item }) => {
27
30
  };
28
31
  const DEBUG_FILE = path.join(os.homedir(), '.blyue', 'last-request.json');
29
32
  function readDebug() {
30
- if (!fs.existsSync(DEBUG_FILE)) {
33
+ if (!fs.existsSync(DEBUG_FILE))
31
34
  return ['No request sent yet in this session.'];
32
- }
33
35
  try {
34
36
  const d = JSON.parse(fs.readFileSync(DEBUG_FILE, 'utf8'));
35
37
  const kb = (d.approxBytes / 1024).toFixed(1);
@@ -58,15 +60,41 @@ export const Chat = ({ resumedCount = 0 }) => {
58
60
  });
59
61
  const [input, setInput] = useState('');
60
62
  const [loading, setLoading] = useState(false);
63
+ const [streaming, setStreaming] = useState('');
61
64
  const [overlay, setOverlay] = useState('none');
62
65
  const [permReq, setPermReq] = useState(null);
63
66
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
64
67
  const [agent, setAgent] = useState(() => new Agent());
68
+ // Mascot state
69
+ const [mascotState, setMascotState] = useState(undefined);
70
+ const [elapsedSec, setElapsedSec] = useState(0);
71
+ const startRef = useRef(null);
65
72
  useEffect(() => {
66
73
  const unsub = permissions.subscribe((req) => setPermReq(req));
67
74
  return unsub;
68
75
  }, []);
76
+ // Track elapsed time during loading
77
+ useEffect(() => {
78
+ if (!loading) {
79
+ startRef.current = null;
80
+ setElapsedSec(0);
81
+ return;
82
+ }
83
+ startRef.current = Date.now();
84
+ const t = setInterval(() => {
85
+ if (startRef.current) {
86
+ setElapsedSec(Math.floor((Date.now() - startRef.current) / 1000));
87
+ }
88
+ }, 500);
89
+ return () => clearInterval(t);
90
+ }, [loading]);
91
+ // Flash a temporary mascot state
92
+ const flash = (state, ms = 1200) => {
93
+ setMascotState(state);
94
+ setTimeout(() => setMascotState(undefined), ms);
95
+ };
69
96
  useInput((inputChar, key) => {
97
+ bumpActivity();
70
98
  if (permReq)
71
99
  return;
72
100
  if (key.ctrl && inputChar === 'p')
@@ -74,10 +102,17 @@ export const Chat = ({ resumedCount = 0 }) => {
74
102
  if (key.ctrl && inputChar === 's')
75
103
  setOverlay('settings');
76
104
  });
105
+ const flush = (kind, text) => {
106
+ if (!text)
107
+ return;
108
+ setHistory((h) => [...h, mkItem(kind, text)]);
109
+ };
77
110
  const handleSubmit = async (text) => {
111
+ bumpActivity();
78
112
  if (!text.trim() || loading)
79
113
  return;
80
114
  const trimmed = text.trim();
115
+ // ---- slash commands ----
81
116
  if (trimmed === '/model' || trimmed === '/models') {
82
117
  setInput('');
83
118
  setOverlay('model');
@@ -98,11 +133,32 @@ export const Chat = ({ resumedCount = 0 }) => {
98
133
  ]);
99
134
  return;
100
135
  }
136
+ if (trimmed === '/compact') {
137
+ setInput('');
138
+ setLoading(true);
139
+ setStreaming('');
140
+ flash('thinking', 1000);
141
+ try {
142
+ const r = await agent.compact();
143
+ setHistory((h) => [
144
+ ...h,
145
+ mkItem('tool', `Compacted: ${r.before} → ${r.after} messages (summary ${r.summaryLen} chars)`),
146
+ ]);
147
+ flash('success', 1500);
148
+ }
149
+ catch (e) {
150
+ setHistory((h) => [...h, mkItem('error', e.message)]);
151
+ flash('error', 2000);
152
+ }
153
+ setLoading(false);
154
+ return;
155
+ }
101
156
  if (trimmed === '/clear') {
102
157
  setInput('');
103
158
  agent.clear();
104
159
  permissions.resetSession();
105
160
  setHistory([mkItem('tool', '(conversation cleared)')]);
161
+ flash('success', 1200);
106
162
  return;
107
163
  }
108
164
  if (trimmed === '/help') {
@@ -112,25 +168,59 @@ export const Chat = ({ resumedCount = 0 }) => {
112
168
  mkItem('tool', 'Commands:'),
113
169
  mkItem('tool', ' /model switch model (Ctrl+P)'),
114
170
  mkItem('tool', ' /settings open settings (Ctrl+S)'),
171
+ mkItem('tool', ' /compact summarize history to free context'),
115
172
  mkItem('tool', ' /debug show last request to provider'),
116
173
  mkItem('tool', ' /clear clear session and permissions'),
117
174
  mkItem('tool', ' /help show this list'),
118
175
  ]);
119
176
  return;
120
177
  }
178
+ // ---- normal run ----
121
179
  setInput('');
122
180
  setHistory((h) => [...h, mkItem('user', trimmed)]);
123
181
  setLoading(true);
182
+ setStreaming('');
183
+ // Jump animation on submit
184
+ flash('jump', 400);
185
+ let textBuf = '';
186
+ let toolBuf = '';
124
187
  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)]);
188
+ for await (const ev of agent.run(trimmed)) {
189
+ if (ev.type === 'text') {
190
+ textBuf += ev.delta;
191
+ setStreaming(textBuf);
192
+ // Clear any tool mascot while text streams
193
+ setMascotState(undefined);
194
+ }
195
+ else if (ev.type === 'tool_start') {
196
+ if (textBuf) {
197
+ flush('ai', textBuf);
198
+ textBuf = '';
199
+ }
200
+ toolBuf = '\n[Calling ' + ev.name + '...]\n';
201
+ setStreaming(toolBuf);
202
+ // React to the specific tool
203
+ setMascotState(toolToState(ev.name));
204
+ }
205
+ else if (ev.type === 'tool_end') {
206
+ flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
207
+ toolBuf = '';
208
+ setStreaming('');
209
+ // Brief pulse before next step
210
+ flash('success', 500);
211
+ }
128
212
  }
213
+ if (textBuf)
214
+ flush('ai', textBuf);
215
+ flash('success', 2000);
129
216
  }
130
217
  catch (e) {
131
218
  setHistory((h) => [...h, mkItem('error', e?.message || String(e))]);
219
+ flash('error', 3000);
132
220
  }
221
+ setStreaming('');
133
222
  setLoading(false);
223
+ setMascotState(undefined);
134
224
  };
135
225
  const handleModelSelect = (id) => {
136
226
  process.env.MODEL = id;
@@ -140,7 +230,7 @@ export const Chat = ({ resumedCount = 0 }) => {
140
230
  setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
141
231
  };
142
232
  if (permReq) {
143
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsx(Box, { marginTop: 1, children: _jsx(PermissionPrompt, { req: permReq }) })] }));
233
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsx(Box, { marginTop: 1, children: _jsx(PermissionPrompt, { req: permReq }) }), _jsx(Box, { marginTop: 1, children: _jsx(InputBar, { value: "", onChange: () => { }, onSubmit: () => { }, loading: false, mascotState: "permission", awaitingPermission: true }) })] }));
144
234
  }
145
235
  if (overlay === 'model') {
146
236
  return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () => setOverlay('none') }) }));
@@ -155,5 +245,5 @@ export const Chat = ({ resumedCount = 0 }) => {
155
245
  const shortModel = currentModel.length > maxModelLen
156
246
  ? '…' + currentModel.slice(-(maxModelLen - 1))
157
247
  : 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 })] })] })] }));
248
+ 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, mascotState: mascotState, elapsedSec: elapsedSec }), _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
249
  };
@@ -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, mascotState, awaitingPermission, elapsedSec = 0, }) => {
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,20 @@ export const InputBar = ({ value, onChange, onSubmit, loading }) => {
17
18
  return () => clearInterval(int);
18
19
  }, [loading]);
19
20
  const hasText = value.length > 0;
20
- const label = loading ? 'thinking' : 'blyue';
21
+ // Priority:
22
+ // explicit mascotState
23
+ // → permission waiting
24
+ // → impatient if running > 8s
25
+ // → thinking if loading
26
+ // → typing if has text
27
+ // → idle
28
+ const state = mascotState ??
29
+ (awaitingPermission ? 'permission' :
30
+ loading && elapsedSec > 8 ? 'impatient' :
31
+ loading ? 'thinking' :
32
+ hasText ? 'typing' : 'idle');
33
+ const label = awaitingPermission ? 'waiting' : loading ? 'thinking' : 'blyue';
21
34
  const status = loading ? ' ' + DOTS[dotI] + ' ' : '';
22
35
  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" })] })] }));
36
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MascotPrompt, { state: state }), _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
37
  };
@@ -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,81 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { theme } from './theme.js';
5
+ import { useIdle, defaultIdleThresholds, idleDisabled } from './useIdle.js';
6
+ import { Zzz } from './SleepZs.js';
7
+ const FACES = {
8
+ idle: ['◕‿◕', '◕‿◕', '◕_◕', '◕‿◕', '◕‿◕', '◕‿◕', '◕_◕', '◕‿◕'],
9
+ typing: ['◉‿◉', '◉‿◉', '◉‿◉', '◉_◉'],
10
+ thinking: ['◐_◐', '◓_◓', '◑_◑', '◒_◒'],
11
+ success: ['^‿^', '^▽^', '^‿^'],
12
+ error: ['◔︵◔', '◔︵◔', '◔_◔'],
13
+ jump: ['◕‿◕', '◉▽◉', '^▽^', '◉▽◉', '◕‿◕'],
14
+ read: ['◉_◉', '◎_◎', '◉_◉'],
15
+ write: ['◕✎◕', '◕✐◕', '◕✎◕'],
16
+ patch: ['◕~◕', '◕≈◕', '◕~◕'],
17
+ list: ['◉◡◉', '◉◠◉', '◉◡◉'],
18
+ command: ['◕⌨◕', '◕⎚◕', '◕⌨◕'],
19
+ automate: ['◕⚙◕', '◕⚙︎◕', '◕⚙◕'],
20
+ permission: ['◔_◔', '◔◡◔', '◔_◔'],
21
+ granted: ['◉‿◉', '◉▽◉', '◉‿◉'],
22
+ denied: ['◔︵◔', '◔_◔', '◔︵◔'],
23
+ impatient: ['◕~◕', '⊙_⊙', '◕~◕', '⊙_⊙'],
24
+ };
25
+ const SPEEDS = {
26
+ idle: 500, typing: 700, thinking: 120,
27
+ success: 300, error: 400, jump: 70,
28
+ read: 220, write: 260, patch: 260, list: 260, command: 220,
29
+ automate: 200, permission: 450, granted: 180, denied: 350, impatient: 300,
30
+ };
31
+ const DROWSY_FACE = '–_–';
32
+ const SLEEPING_FACE = '-‿-';
33
+ // Reserved vertical rows above the face. Every state renders exactly
34
+ // this many rows so Ink never has to reflow the layout below us.
35
+ const TOP_ROWS = 2;
36
+ export function toolToState(tool) {
37
+ if (tool === 'read_file')
38
+ return 'read';
39
+ if (tool === 'write_file')
40
+ return 'write';
41
+ if (tool === 'apply_patch')
42
+ return 'patch';
43
+ if (tool === 'list_files')
44
+ return 'list';
45
+ if (tool === 'run_command')
46
+ return 'command';
47
+ if (tool.startsWith('n8n_'))
48
+ return 'automate';
49
+ return 'thinking';
50
+ }
51
+ const BlankRows = () => (_jsx(_Fragment, { children: Array.from({ length: TOP_ROWS }, (_, i) => (_jsx(Text, { children: " " }, i))) }));
52
+ export const MascotPrompt = ({ state, keepAwake = false, }) => {
53
+ const frames = FACES[state];
54
+ const [i, setI] = useState(0);
55
+ const thresholds = defaultIdleThresholds();
56
+ const idle = useIdle({
57
+ ...thresholds,
58
+ disabled: keepAwake || idleDisabled(),
59
+ });
60
+ useEffect(() => {
61
+ setI(0);
62
+ if (frames.length <= 1)
63
+ return;
64
+ const t = setInterval(() => setI((v) => (v + 1) % frames.length), SPEEDS[state]);
65
+ return () => clearInterval(t);
66
+ }, [state, frames.length]);
67
+ if (idle === 'sleeping') {
68
+ return (_jsxs(Box, { flexDirection: "column", height: TOP_ROWS + 1, children: [_jsx(Zzz, {}), _jsx(Text, { color: theme.muted, bold: true, children: ' ' + SLEEPING_FACE })] }));
69
+ }
70
+ if (idle === 'drowsy') {
71
+ return (_jsxs(Box, { flexDirection: "column", height: TOP_ROWS + 1, children: [_jsx(BlankRows, {}), _jsx(Text, { color: theme.muted, bold: true, children: ' ' + DROWSY_FACE })] }));
72
+ }
73
+ const color = state === 'error' || state === 'denied'
74
+ ? theme.error
75
+ : state === 'success' || state === 'granted'
76
+ ? theme.success
77
+ : state === 'permission' || state === 'impatient'
78
+ ? theme.warning
79
+ : theme.accent;
80
+ return (_jsxs(Box, { flexDirection: "column", height: TOP_ROWS + 1, children: [_jsx(BlankRows, {}), _jsx(Text, { color: color, bold: true, children: ' ' + frames[i] })] }));
81
+ };
@@ -0,0 +1,26 @@
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 { theme } from './theme.js';
5
+ // Two rows, always exactly two rows — the mascot container reserves this
6
+ // space whether a z is visible or not, so Ink never reflows.
7
+ const FRAMES = [
8
+ [' ', ' '],
9
+ [' ', ' z '],
10
+ [' ', ' z '],
11
+ [' z ', ' z '],
12
+ [' z ', ' z '],
13
+ [' z ', ' '],
14
+ [' z', ' '],
15
+ [' ', ' '],
16
+ ];
17
+ const SPEED_MS = 800;
18
+ export const Zzz = () => {
19
+ const [i, setI] = useState(0);
20
+ useEffect(() => {
21
+ const t = setInterval(() => setI((v) => (v + 1) % FRAMES.length), SPEED_MS);
22
+ return () => clearInterval(t);
23
+ }, []);
24
+ const [far, near] = FRAMES[i];
25
+ return (_jsxs(Box, { flexDirection: "column", height: 2, children: [_jsx(Text, { color: theme.muted, children: far }), _jsx(Text, { color: theme.muted, children: near })] }));
26
+ };
@@ -0,0 +1,20 @@
1
+ // Tiny global activity bus — avoids prop-drilling an `activeSignal` number
2
+ // through every component. Chat.tsx calls bumpActivity() from useInput;
3
+ // MascotPrompt subscribes via useIdle().
4
+ let counter = 0;
5
+ const listeners = new Set();
6
+ /** Call this from any real user input handler (keystroke, submit). */
7
+ export function bumpActivity() {
8
+ counter = (counter + 1) % Number.MAX_SAFE_INTEGER;
9
+ for (const l of listeners)
10
+ l();
11
+ }
12
+ export function getActivity() {
13
+ return counter;
14
+ }
15
+ export function subscribeActivity(fn) {
16
+ listeners.add(fn);
17
+ return () => {
18
+ listeners.delete(fn);
19
+ };
20
+ }
@@ -0,0 +1,43 @@
1
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { subscribeActivity, getActivity } from './activity.js';
3
+ export function useActivity() {
4
+ return useSyncExternalStore(subscribeActivity, getActivity, getActivity);
5
+ }
6
+ export function useIdle({ drowsyAfterMs = 120_000, sleepAfterMs = 300_000, disabled = false, } = {}) {
7
+ const active = useActivity();
8
+ const [stage, setStage] = useState('awake');
9
+ const timersRef = useRef([]);
10
+ useEffect(() => {
11
+ for (const t of timersRef.current)
12
+ clearTimeout(t);
13
+ timersRef.current = [];
14
+ if (disabled) {
15
+ setStage('awake');
16
+ return;
17
+ }
18
+ setStage('awake');
19
+ const drowsyT = setTimeout(() => setStage('drowsy'), drowsyAfterMs);
20
+ const sleepT = setTimeout(() => setStage('sleeping'), sleepAfterMs);
21
+ timersRef.current = [drowsyT, sleepT];
22
+ return () => {
23
+ for (const t of timersRef.current)
24
+ clearTimeout(t);
25
+ timersRef.current = [];
26
+ };
27
+ }, [active, drowsyAfterMs, sleepAfterMs, disabled]);
28
+ return stage;
29
+ }
30
+ /**
31
+ * Idle thresholds.
32
+ * BLYUE_FAST_IDLE=1 -> 3s / 6s (dev testing only)
33
+ * default -> 2 min drowsy / 5 min sleep
34
+ */
35
+ export function defaultIdleThresholds() {
36
+ if (process.env.BLYUE_FAST_IDLE === '1') {
37
+ return { drowsyAfterMs: 3_000, sleepAfterMs: 6_000 };
38
+ }
39
+ return { drowsyAfterMs: 120_000, sleepAfterMs: 300_000 };
40
+ }
41
+ export function idleDisabled() {
42
+ return process.env.BLYUE_NO_SLEEP === '1';
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.1.12",
3
+ "version": "0.1.15",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",