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.
- package/dist/core/agent.js +69 -10
- package/dist/core/provider.js +81 -24
- package/dist/core/tools.js +71 -3
- package/dist/index.js +1 -1
- package/dist/ui/Banner.js +4 -4
- package/dist/ui/Chat.js +98 -8
- package/dist/ui/InputBar.js +16 -3
- package/dist/ui/MakerMark.js +10 -2
- package/dist/ui/Mascot.js +47 -0
- package/dist/ui/MascotPrompt.js +81 -0
- package/dist/ui/SleepZs.js +26 -0
- package/dist/ui/activity.js +20 -0
- package/dist/ui/useIdle.js +43 -0
- package/package.json +1 -1
package/dist/core/agent.js
CHANGED
|
@@ -25,7 +25,7 @@ export class Agent {
|
|
|
25
25
|
}
|
|
26
26
|
persist() {
|
|
27
27
|
try {
|
|
28
|
-
saveSession(this.messages.slice(1));
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
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:
|
|
55
|
-
tool_calls:
|
|
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
|
|
62
|
-
yield
|
|
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
|
|
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
|
}
|
package/dist/core/provider.js
CHANGED
|
@@ -37,16 +37,81 @@ export class Provider {
|
|
|
37
37
|
}
|
|
38
38
|
catch { }
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
const
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/tools.js
CHANGED
|
@@ -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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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: "/
|
|
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
|
};
|
package/dist/ui/InputBar.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/ui/MakerMark.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Text } from 'ink';
|
|
3
|
-
|
|
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
|
+
}
|