blyuecode 0.1.14 → 0.1.16

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/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,11 +1,11 @@
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';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { dirname, join } from 'path';
7
7
  import os from 'os';
8
- import { hueAt } from './animColor.js';
8
+ import { pastelAt } from './pastel.js';
9
9
  function getVersion() {
10
10
  try {
11
11
  const here = dirname(fileURLToPath(import.meta.url));
@@ -63,6 +63,7 @@ const CODE_SMALL = [
63
63
  ];
64
64
  const BLYUE_WIDTH = 44;
65
65
  const BLYUE_SMALL_WIDTH = 27;
66
+ const MakerMarkInline = ({ color }) => (_jsx(Text, { color: color, bold: true, children: "\u25C6 L U M E N" }));
66
67
  export const Banner = () => {
67
68
  const { stdout } = useStdout();
68
69
  const width = stdout?.columns ?? 80;
@@ -70,21 +71,19 @@ export const Banner = () => {
70
71
  const blyue = wide ? BLYUE_BIG : BLYUE_SMALL;
71
72
  const code = wide ? CODE_BIG : CODE_SMALL;
72
73
  const blockWidth = wide ? BLYUE_WIDTH : BLYUE_SMALL_WIDTH;
73
- // Left padding to center whole block in terminal
74
74
  const leftPad = Math.max(0, Math.floor((width - blockWidth) / 2));
75
75
  const pad = ' '.repeat(leftPad);
76
76
  const [animate] = useState(() => shouldAnimate());
77
77
  const [t, setT] = useState(0);
78
+ // Slow pastel drift — 24s full cycle, matches the rest of the TUI.
78
79
  useEffect(() => {
79
80
  if (!animate)
80
81
  return;
81
- const interval = setInterval(() => setT((v) => (v + 1 / 320) % 1), 120);
82
+ const interval = setInterval(() => setT((v) => (v + 1 / 200) % 1), 120);
82
83
  return () => clearInterval(interval);
83
84
  }, [animate]);
84
- const baseHue = animate ? t : 0.1;
85
- const brand = hueAt(baseHue);
85
+ const baseHue = animate ? t : 0.08;
86
+ const brand = pastelAt(baseHue);
86
87
  const version = getVersion();
87
- 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
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [pad, _jsx(MakerMarkInline, { color: brand })] }), _jsx(Text, { children: " " }), blyue.map((line, i) => (_jsx(Text, { color: pastelAt((baseHue + i * 0.035) % 1), bold: true, children: pad + line }, 'b' + i))), code.map((line, i) => (_jsx(Text, { color: pastelAt((baseHue + i * 0.035 + 0.18) % 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
89
  };
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';
@@ -11,12 +11,16 @@ import { ModelPicker } from './ModelPicker.js';
11
11
  import { Settings } from './Settings.js';
12
12
  import { PermissionPrompt } from './PermissionPrompt.js';
13
13
  import { Spinner } from './Spinner.js';
14
+ import { toolToState } from './MascotPrompt.js';
15
+ import { useSlowColor } from './useSlowColor.js';
16
+ import { pastelAt } from './pastel.js';
17
+ import { bumpActivity } from './activity.js';
14
18
  import { theme } from './theme.js';
15
19
  let nextId = 0;
16
20
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
17
21
  const HistoryLine = ({ item }) => {
18
22
  if (item.kind === 'user') {
19
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(Text, { color: theme.text, bold: true, children: item.text })] }));
23
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: pastelAt(0.08), bold: true, children: '❯ ' }), _jsx(Text, { color: theme.text, bold: true, children: item.text })] }));
20
24
  }
21
25
  if (item.kind === 'error') {
22
26
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.error, children: '❯ ' }), _jsx(Text, { color: theme.error, children: item.text })] }));
@@ -63,12 +67,37 @@ export const Chat = ({ resumedCount = 0 }) => {
63
67
  const [permReq, setPermReq] = useState(null);
64
68
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
65
69
  const [agent, setAgent] = useState(() => new Agent());
66
- const [reaction, setReaction] = useState(null);
70
+ // Mascot state
71
+ const [mascotState, setMascotState] = useState(undefined);
72
+ const [elapsedSec, setElapsedSec] = useState(0);
73
+ const startRef = useRef(null);
67
74
  useEffect(() => {
68
75
  const unsub = permissions.subscribe((req) => setPermReq(req));
69
76
  return unsub;
70
77
  }, []);
78
+ // Track elapsed time during loading
79
+ useEffect(() => {
80
+ if (!loading) {
81
+ startRef.current = null;
82
+ setElapsedSec(0);
83
+ return;
84
+ }
85
+ startRef.current = Date.now();
86
+ const t = setInterval(() => {
87
+ if (startRef.current) {
88
+ setElapsedSec(Math.floor((Date.now() - startRef.current) / 1000));
89
+ }
90
+ }, 500);
91
+ return () => clearInterval(t);
92
+ }, [loading]);
93
+ // Flash a temporary mascot state
94
+ const flash = (state, ms = 1200) => {
95
+ setMascotState(state);
96
+ setTimeout(() => setMascotState(undefined), ms);
97
+ };
98
+ const slowColor = useSlowColor();
71
99
  useInput((inputChar, key) => {
100
+ bumpActivity();
72
101
  if (permReq)
73
102
  return;
74
103
  if (key.ctrl && inputChar === 'p')
@@ -82,6 +111,7 @@ export const Chat = ({ resumedCount = 0 }) => {
82
111
  setHistory((h) => [...h, mkItem(kind, text)]);
83
112
  };
84
113
  const handleSubmit = async (text) => {
114
+ bumpActivity();
85
115
  if (!text.trim() || loading)
86
116
  return;
87
117
  const trimmed = text.trim();
@@ -110,15 +140,18 @@ export const Chat = ({ resumedCount = 0 }) => {
110
140
  setInput('');
111
141
  setLoading(true);
112
142
  setStreaming('');
143
+ flash('thinking', 1000);
113
144
  try {
114
145
  const r = await agent.compact();
115
146
  setHistory((h) => [
116
147
  ...h,
117
148
  mkItem('tool', `Compacted: ${r.before} → ${r.after} messages (summary ${r.summaryLen} chars)`),
118
149
  ]);
150
+ flash('success', 1500);
119
151
  }
120
152
  catch (e) {
121
153
  setHistory((h) => [...h, mkItem('error', e.message)]);
154
+ flash('error', 2000);
122
155
  }
123
156
  setLoading(false);
124
157
  return;
@@ -128,6 +161,7 @@ export const Chat = ({ resumedCount = 0 }) => {
128
161
  agent.clear();
129
162
  permissions.resetSession();
130
163
  setHistory([mkItem('tool', '(conversation cleared)')]);
164
+ flash('success', 1200);
131
165
  return;
132
166
  }
133
167
  if (trimmed === '/help') {
@@ -150,8 +184,7 @@ export const Chat = ({ resumedCount = 0 }) => {
150
184
  setLoading(true);
151
185
  setStreaming('');
152
186
  // Jump animation on submit
153
- setReaction('jump');
154
- setTimeout(() => setReaction(null), 400);
187
+ flash('jump', 400);
155
188
  let textBuf = '';
156
189
  let toolBuf = '';
157
190
  try {
@@ -159,6 +192,8 @@ export const Chat = ({ resumedCount = 0 }) => {
159
192
  if (ev.type === 'text') {
160
193
  textBuf += ev.delta;
161
194
  setStreaming(textBuf);
195
+ // Clear any tool mascot while text streams
196
+ setMascotState(undefined);
162
197
  }
163
198
  else if (ev.type === 'tool_start') {
164
199
  if (textBuf) {
@@ -167,26 +202,28 @@ export const Chat = ({ resumedCount = 0 }) => {
167
202
  }
168
203
  toolBuf = '\n[Calling ' + ev.name + '...]\n';
169
204
  setStreaming(toolBuf);
205
+ // React to the specific tool
206
+ setMascotState(toolToState(ev.name));
170
207
  }
171
208
  else if (ev.type === 'tool_end') {
172
209
  flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
173
210
  toolBuf = '';
174
211
  setStreaming('');
212
+ // Brief pulse before next step
213
+ flash('success', 500);
175
214
  }
176
215
  }
177
216
  if (textBuf)
178
217
  flush('ai', textBuf);
179
- // Success reaction
180
- setReaction('success');
181
- setTimeout(() => setReaction(null), 2000);
218
+ flash('success', 2000);
182
219
  }
183
220
  catch (e) {
184
221
  setHistory((h) => [...h, mkItem('error', e?.message || String(e))]);
185
- setReaction('error');
186
- setTimeout(() => setReaction(null), 3000);
222
+ flash('error', 3000);
187
223
  }
188
224
  setStreaming('');
189
225
  setLoading(false);
226
+ setMascotState(undefined);
190
227
  };
191
228
  const handleModelSelect = (id) => {
192
229
  process.env.MODEL = id;
@@ -196,7 +233,7 @@ export const Chat = ({ resumedCount = 0 }) => {
196
233
  setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
197
234
  };
198
235
  if (permReq) {
199
- 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 }) })] }));
236
+ 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 }) })] }));
200
237
  }
201
238
  if (overlay === 'model') {
202
239
  return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () => setOverlay('none') }) }));
@@ -211,5 +248,5 @@ export const Chat = ({ resumedCount = 0 }) => {
211
248
  const shortModel = currentModel.length > maxModelLen
212
249
  ? '…' + currentModel.slice(-(maxModelLen - 1))
213
250
  : currentModel || 'unset';
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 })] })] })] }));
251
+ 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: slowColor, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: slowColor, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: slowColor, children: "/compact" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
215
252
  };
@@ -6,10 +6,12 @@ import { Placeholder } from './Placeholder.js';
6
6
  import { RocketBar } from './RocketBar.js';
7
7
  import { MascotPrompt } from './MascotPrompt.js';
8
8
  import { theme } from './theme.js';
9
+ import { useSlowColor } from './useSlowColor.js';
9
10
  const DOTS = ['●○○○', '○●○○', '○○●○', '○○○●'];
10
- export const InputBar = ({ value, onChange, onSubmit, loading, reaction }) => {
11
+ export const InputBar = ({ value, onChange, onSubmit, loading, mascotState, awaitingPermission, elapsedSec = 0, }) => {
11
12
  const { stdout } = useStdout();
12
13
  const width = Math.max(24, stdout?.columns ?? 50);
14
+ const pastel = useSlowColor();
13
15
  const [dotI, setDotI] = useState(0);
14
16
  useEffect(() => {
15
17
  if (!loading)
@@ -18,11 +20,13 @@ export const InputBar = ({ value, onChange, onSubmit, loading, reaction }) => {
18
20
  return () => clearInterval(int);
19
21
  }, [loading]);
20
22
  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');
24
- const label = loading ? 'thinking' : 'blyue';
23
+ const state = mascotState ??
24
+ (awaitingPermission ? 'permission' :
25
+ loading && elapsedSec > 8 ? 'impatient' :
26
+ loading ? 'thinking' :
27
+ hasText ? 'typing' : 'idle');
28
+ const label = awaitingPermission ? 'waiting' : loading ? 'thinking' : 'blyue';
25
29
  const status = loading ? ' ' + DOTS[dotI] + ' ' : '';
26
30
  const botLen = Math.max(0, width - 2 - status.length);
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" })] })] }));
31
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MascotPrompt, { state: state }), _jsx(RocketBar, { label: label, loading: loading, width: width }), _jsxs(Box, { children: [_jsx(Text, { color: pastel, 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: pastel, children: status }), _jsx(Text, { color: theme.muted, children: "\u256F" })] })] }));
28
32
  };
@@ -1,8 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
- import { Text } from 'ink';
3
+ import { Box, Text } from 'ink';
4
4
  import { theme } from './theme.js';
5
- // Face frames per state
5
+ import { useIdle, defaultIdleThresholds, idleDisabled } from './useIdle.js';
6
+ import { useSlowColor } from './useSlowColor.js';
7
+ import { Zzz } from './SleepZs.js';
6
8
  const FACES = {
7
9
  idle: ['◕‿◕', '◕‿◕', '◕_◕', '◕‿◕', '◕‿◕', '◕‿◕', '◕_◕', '◕‿◕'],
8
10
  typing: ['◉‿◉', '◉‿◉', '◉‿◉', '◉_◉'],
@@ -10,19 +12,65 @@ const FACES = {
10
12
  success: ['^‿^', '^▽^', '^‿^'],
11
13
  error: ['◔︵◔', '◔︵◔', '◔_◔'],
12
14
  jump: ['◕‿◕', '◉▽◉', '^▽^', '◉▽◉', '◕‿◕'],
15
+ read: ['◉_◉', '◎_◎', '◉_◉'],
16
+ write: ['◕✎◕', '◕✐◕', '◕✎◕'],
17
+ patch: ['◕~◕', '◕≈◕', '◕~◕'],
18
+ list: ['◉◡◉', '◉◠◉', '◉◡◉'],
19
+ command: ['◕⌨◕', '◕⎚◕', '◕⌨◕'],
20
+ automate: ['◕⚙◕', '◕⚙︎◕', '◕⚙◕'],
21
+ permission: ['◔_◔', '◔◡◔', '◔_◔'],
22
+ granted: ['◉‿◉', '◉▽◉', '◉‿◉'],
23
+ denied: ['◔︵◔', '◔_◔', '◔︵◔'],
24
+ impatient: ['◕~◕', '⊙_⊙', '◕~◕', '⊙_⊙'],
13
25
  };
14
- // Frame speeds in ms
15
26
  const SPEEDS = {
16
- idle: 500,
17
- typing: 700,
18
- thinking: 120,
19
- success: 300,
20
- error: 400,
21
- jump: 70,
27
+ idle: 500, typing: 700, thinking: 120,
28
+ success: 300, error: 400, jump: 70,
29
+ read: 220, write: 260, patch: 260, list: 260, command: 220,
30
+ automate: 200, permission: 450, granted: 180, denied: 350, impatient: 300,
22
31
  };
23
- export const MascotPrompt = ({ state }) => {
32
+ const DROWSY_FACE = '–_–';
33
+ const SLEEPING_FACE = '-‿-';
34
+ const TOP_ROWS = 2;
35
+ export function toolToState(tool) {
36
+ if (tool === 'read_file')
37
+ return 'read';
38
+ if (tool === 'write_file')
39
+ return 'write';
40
+ if (tool === 'apply_patch')
41
+ return 'patch';
42
+ if (tool === 'list_files')
43
+ return 'list';
44
+ if (tool === 'run_command')
45
+ return 'command';
46
+ if (tool.startsWith('n8n_'))
47
+ return 'automate';
48
+ return 'thinking';
49
+ }
50
+ const BlankRows = () => (_jsx(_Fragment, { children: Array.from({ length: TOP_ROWS }, (_, i) => (_jsx(Text, { children: " " }, i))) }));
51
+ // States whose color is semantic (meaning-bearing) — these stay their
52
+ // theme colors. Everything else drifts through the pastel palette.
53
+ function isSemantic(state) {
54
+ return (state === 'error' || state === 'denied' ||
55
+ state === 'success' || state === 'granted' ||
56
+ state === 'permission' || state === 'impatient');
57
+ }
58
+ function semanticColor(state) {
59
+ if (state === 'error' || state === 'denied')
60
+ return theme.error;
61
+ if (state === 'success' || state === 'granted')
62
+ return theme.success;
63
+ return theme.warning; // permission / impatient
64
+ }
65
+ export const MascotPrompt = ({ state, keepAwake = false, }) => {
24
66
  const frames = FACES[state];
25
67
  const [i, setI] = useState(0);
68
+ const thresholds = defaultIdleThresholds();
69
+ const idle = useIdle({
70
+ ...thresholds,
71
+ disabled: keepAwake || idleDisabled(),
72
+ });
73
+ const slowColor = useSlowColor();
26
74
  useEffect(() => {
27
75
  setI(0);
28
76
  if (frames.length <= 1)
@@ -30,9 +78,12 @@ export const MascotPrompt = ({ state }) => {
30
78
  const t = setInterval(() => setI((v) => (v + 1) % frames.length), SPEEDS[state]);
31
79
  return () => clearInterval(t);
32
80
  }, [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] }) }));
81
+ if (idle === 'sleeping') {
82
+ return (_jsxs(Box, { flexDirection: "column", height: TOP_ROWS + 1, children: [_jsx(Zzz, {}), _jsx(Text, { color: theme.muted, bold: true, children: ' ' + SLEEPING_FACE })] }));
83
+ }
84
+ if (idle === 'drowsy') {
85
+ return (_jsxs(Box, { flexDirection: "column", height: TOP_ROWS + 1, children: [_jsx(BlankRows, {}), _jsx(Text, { color: theme.muted, bold: true, children: ' ' + DROWSY_FACE })] }));
86
+ }
87
+ const color = isSemantic(state) ? semanticColor(state) : slowColor;
88
+ return (_jsxs(Box, { flexDirection: "column", height: TOP_ROWS + 1, children: [_jsx(BlankRows, {}), _jsx(Text, { color: color, bold: true, children: ' ' + frames[i] })] }));
38
89
  };
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
4
  import os from 'os';
5
5
  import { theme } from './theme.js';
6
- import { hueAt } from './animColor.js';
6
+ import { useSlowColor } from './useSlowColor.js';
7
7
  function shouldAnimate() {
8
8
  if (process.env.BLYUE_ANIMATE === '1')
9
9
  return true;
@@ -25,20 +25,13 @@ function shouldAnimate() {
25
25
  }
26
26
  export const RocketBar = ({ label, loading, width }) => {
27
27
  const [animate] = useState(() => shouldAnimate());
28
- const [t, setT] = useState(0);
29
28
  const [pos, setPos] = useState(0);
30
- useEffect(() => {
31
- if (!animate)
32
- return;
33
- const int = setInterval(() => setT((v) => (v + 0.00625) % 1), 120);
34
- return () => clearInterval(int);
35
- }, [animate]);
29
+ const hue = useSlowColor(); // shared pastel clock — matches banner + face
36
30
  const fixed = 5 + label.length;
37
31
  const midLen = Math.max(0, width - fixed);
38
32
  useEffect(() => {
39
33
  if (!loading || midLen < 6)
40
34
  return;
41
- // Static mode: no rocket motion, just fixed at 1/3 position
42
35
  if (!animate) {
43
36
  setPos(Math.floor(midLen / 3));
44
37
  return;
@@ -47,9 +40,8 @@ export const RocketBar = ({ label, loading, width }) => {
47
40
  const int = setInterval(() => setPos((p) => (p + 1) % track), 90);
48
41
  return () => clearInterval(int);
49
42
  }, [loading, midLen, animate]);
50
- const hue = animate ? hueAt(t) : hueAt(0.1);
51
43
  if (!loading || midLen < 6) {
52
- return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: loading ? hue : theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(midLen) + '╮' })] }));
44
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: hue, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(midLen) + '╮' })] }));
53
45
  }
54
46
  const p = Math.min(pos, Math.max(0, midLen - 3));
55
47
  const before = '─'.repeat(p);
@@ -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,30 @@
1
+ // Pastel/faded palette — Claude-Code-inspired soft muting.
2
+ // All colors sit at ~76% lightness / ~48% saturation, so they read as
3
+ // "faded" on both dark and light terminals.
4
+ function hslToHex(h, s, l) {
5
+ s /= 100;
6
+ l /= 100;
7
+ const k = (n) => (n + h / 30) % 12;
8
+ const a = s * Math.min(l, 1 - l);
9
+ const f = (n) => {
10
+ const c = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
11
+ return Math.round(255 * c);
12
+ };
13
+ const toHex = (x) => x.toString(16).padStart(2, '0');
14
+ return '#' + toHex(f(0)) + toHex(f(8)) + toHex(f(4));
15
+ }
16
+ /** Full-hue pastel cycle. t in [0, 1). */
17
+ export function pastelAt(t) {
18
+ const h = (((t % 1) + 1) % 1) * 360;
19
+ return hslToHex(h, 48, 76);
20
+ }
21
+ /**
22
+ * Warm-only variant — rose → peach → gold → sage → back. If the full
23
+ * wheel feels too rainbow-y for your taste, swap pastelAt -> pastelWarmAt
24
+ * in the four call sites below.
25
+ */
26
+ export function pastelWarmAt(t) {
27
+ const t2 = ((t % 1) + 1) % 1;
28
+ const h = (320 + t2 * 110) % 360; // 320° → 70°, wraps through warm
29
+ return hslToHex(h, 52, 76);
30
+ }
@@ -0,0 +1,38 @@
1
+ // A single module-level tick shared by every component that wants to
2
+ // fade in sync. One interval, N subscribers, no per-component drift.
3
+ const PERIOD_MS = 24_000; // full pastel cycle
4
+ const TICK_MS = 120;
5
+ let t = 0;
6
+ const listeners = new Set();
7
+ let interval = null;
8
+ function start() {
9
+ if (interval)
10
+ return;
11
+ interval = setInterval(() => {
12
+ t = (t + TICK_MS / PERIOD_MS) % 1;
13
+ for (const l of listeners)
14
+ l();
15
+ }, TICK_MS);
16
+ // Don't keep the Node process alive just to animate colors.
17
+ const anyInt = interval;
18
+ anyInt.unref?.();
19
+ }
20
+ function stop() {
21
+ if (interval) {
22
+ clearInterval(interval);
23
+ interval = null;
24
+ }
25
+ }
26
+ export function subscribeSlowTick(fn) {
27
+ listeners.add(fn);
28
+ if (listeners.size === 1)
29
+ start();
30
+ return () => {
31
+ listeners.delete(fn);
32
+ if (listeners.size === 0)
33
+ stop();
34
+ };
35
+ }
36
+ export function getSlowTick() {
37
+ return t;
38
+ }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { subscribeSlowTick, getSlowTick } from './slowTick.js';
3
+ import { pastelAt, pastelWarmAt } from './pastel.js';
4
+ /**
5
+ * Returns a pastel hex color that drifts slowly and stays in sync with
6
+ * every other component that uses this hook.
7
+ */
8
+ export function useSlowColor(warm = false) {
9
+ const t = useSyncExternalStore(subscribeSlowTick, getSlowTick, getSlowTick);
10
+ return warm ? pastelWarmAt(t) : pastelAt(t);
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",