blyuecode 0.1.14 → 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/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';
@@ -11,6 +11,8 @@ 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 { bumpActivity } from './activity.js';
14
16
  import { theme } from './theme.js';
15
17
  let nextId = 0;
16
18
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
@@ -63,12 +65,36 @@ export const Chat = ({ resumedCount = 0 }) => {
63
65
  const [permReq, setPermReq] = useState(null);
64
66
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
65
67
  const [agent, setAgent] = useState(() => new Agent());
66
- const [reaction, setReaction] = useState(null);
68
+ // Mascot state
69
+ const [mascotState, setMascotState] = useState(undefined);
70
+ const [elapsedSec, setElapsedSec] = useState(0);
71
+ const startRef = useRef(null);
67
72
  useEffect(() => {
68
73
  const unsub = permissions.subscribe((req) => setPermReq(req));
69
74
  return unsub;
70
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
+ };
71
96
  useInput((inputChar, key) => {
97
+ bumpActivity();
72
98
  if (permReq)
73
99
  return;
74
100
  if (key.ctrl && inputChar === 'p')
@@ -82,6 +108,7 @@ export const Chat = ({ resumedCount = 0 }) => {
82
108
  setHistory((h) => [...h, mkItem(kind, text)]);
83
109
  };
84
110
  const handleSubmit = async (text) => {
111
+ bumpActivity();
85
112
  if (!text.trim() || loading)
86
113
  return;
87
114
  const trimmed = text.trim();
@@ -110,15 +137,18 @@ export const Chat = ({ resumedCount = 0 }) => {
110
137
  setInput('');
111
138
  setLoading(true);
112
139
  setStreaming('');
140
+ flash('thinking', 1000);
113
141
  try {
114
142
  const r = await agent.compact();
115
143
  setHistory((h) => [
116
144
  ...h,
117
145
  mkItem('tool', `Compacted: ${r.before} → ${r.after} messages (summary ${r.summaryLen} chars)`),
118
146
  ]);
147
+ flash('success', 1500);
119
148
  }
120
149
  catch (e) {
121
150
  setHistory((h) => [...h, mkItem('error', e.message)]);
151
+ flash('error', 2000);
122
152
  }
123
153
  setLoading(false);
124
154
  return;
@@ -128,6 +158,7 @@ export const Chat = ({ resumedCount = 0 }) => {
128
158
  agent.clear();
129
159
  permissions.resetSession();
130
160
  setHistory([mkItem('tool', '(conversation cleared)')]);
161
+ flash('success', 1200);
131
162
  return;
132
163
  }
133
164
  if (trimmed === '/help') {
@@ -150,8 +181,7 @@ export const Chat = ({ resumedCount = 0 }) => {
150
181
  setLoading(true);
151
182
  setStreaming('');
152
183
  // Jump animation on submit
153
- setReaction('jump');
154
- setTimeout(() => setReaction(null), 400);
184
+ flash('jump', 400);
155
185
  let textBuf = '';
156
186
  let toolBuf = '';
157
187
  try {
@@ -159,6 +189,8 @@ export const Chat = ({ resumedCount = 0 }) => {
159
189
  if (ev.type === 'text') {
160
190
  textBuf += ev.delta;
161
191
  setStreaming(textBuf);
192
+ // Clear any tool mascot while text streams
193
+ setMascotState(undefined);
162
194
  }
163
195
  else if (ev.type === 'tool_start') {
164
196
  if (textBuf) {
@@ -167,26 +199,28 @@ export const Chat = ({ resumedCount = 0 }) => {
167
199
  }
168
200
  toolBuf = '\n[Calling ' + ev.name + '...]\n';
169
201
  setStreaming(toolBuf);
202
+ // React to the specific tool
203
+ setMascotState(toolToState(ev.name));
170
204
  }
171
205
  else if (ev.type === 'tool_end') {
172
206
  flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
173
207
  toolBuf = '';
174
208
  setStreaming('');
209
+ // Brief pulse before next step
210
+ flash('success', 500);
175
211
  }
176
212
  }
177
213
  if (textBuf)
178
214
  flush('ai', textBuf);
179
- // Success reaction
180
- setReaction('success');
181
- setTimeout(() => setReaction(null), 2000);
215
+ flash('success', 2000);
182
216
  }
183
217
  catch (e) {
184
218
  setHistory((h) => [...h, mkItem('error', e?.message || String(e))]);
185
- setReaction('error');
186
- setTimeout(() => setReaction(null), 3000);
219
+ flash('error', 3000);
187
220
  }
188
221
  setStreaming('');
189
222
  setLoading(false);
223
+ setMascotState(undefined);
190
224
  };
191
225
  const handleModelSelect = (id) => {
192
226
  process.env.MODEL = id;
@@ -196,7 +230,7 @@ export const Chat = ({ resumedCount = 0 }) => {
196
230
  setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
197
231
  };
198
232
  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 }) })] }));
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 }) })] }));
200
234
  }
201
235
  if (overlay === 'model') {
202
236
  return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () => setOverlay('none') }) }));
@@ -211,5 +245,5 @@ export const Chat = ({ resumedCount = 0 }) => {
211
245
  const shortModel = currentModel.length > maxModelLen
212
246
  ? '…' + currentModel.slice(-(maxModelLen - 1))
213
247
  : 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 })] })] })] }));
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 })] })] })] }));
215
249
  };
@@ -7,7 +7,7 @@ import { RocketBar } from './RocketBar.js';
7
7
  import { MascotPrompt } from './MascotPrompt.js';
8
8
  import { theme } from './theme.js';
9
9
  const DOTS = ['●○○○', '○●○○', '○○●○', '○○○●'];
10
- export const InputBar = ({ value, onChange, onSubmit, loading, reaction }) => {
10
+ export const InputBar = ({ value, onChange, onSubmit, loading, mascotState, awaitingPermission, elapsedSec = 0, }) => {
11
11
  const { stdout } = useStdout();
12
12
  const width = Math.max(24, stdout?.columns ?? 50);
13
13
  const [dotI, setDotI] = useState(0);
@@ -18,11 +18,20 @@ export const InputBar = ({ value, onChange, onSubmit, loading, reaction }) => {
18
18
  return () => clearInterval(int);
19
19
  }, [loading]);
20
20
  const hasText = value.length > 0;
21
- // Decide mascot state, priority order:
22
- // reaction > loading > typing > idle
23
- const mascotState = reaction ?? (loading ? 'thinking' : hasText ? 'typing' : 'idle');
24
- 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';
25
34
  const status = loading ? ' ' + DOTS[dotI] + ' ' : '';
26
35
  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" })] })] }));
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" })] })] }));
28
37
  };
@@ -1,8 +1,9 @@
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 { Zzz } from './SleepZs.js';
6
7
  const FACES = {
7
8
  idle: ['◕‿◕', '◕‿◕', '◕_◕', '◕‿◕', '◕‿◕', '◕‿◕', '◕_◕', '◕‿◕'],
8
9
  typing: ['◉‿◉', '◉‿◉', '◉‿◉', '◉_◉'],
@@ -10,19 +11,52 @@ const FACES = {
10
11
  success: ['^‿^', '^▽^', '^‿^'],
11
12
  error: ['◔︵◔', '◔︵◔', '◔_◔'],
12
13
  jump: ['◕‿◕', '◉▽◉', '^▽^', '◉▽◉', '◕‿◕'],
14
+ read: ['◉_◉', '◎_◎', '◉_◉'],
15
+ write: ['◕✎◕', '◕✐◕', '◕✎◕'],
16
+ patch: ['◕~◕', '◕≈◕', '◕~◕'],
17
+ list: ['◉◡◉', '◉◠◉', '◉◡◉'],
18
+ command: ['◕⌨◕', '◕⎚◕', '◕⌨◕'],
19
+ automate: ['◕⚙◕', '◕⚙︎◕', '◕⚙◕'],
20
+ permission: ['◔_◔', '◔◡◔', '◔_◔'],
21
+ granted: ['◉‿◉', '◉▽◉', '◉‿◉'],
22
+ denied: ['◔︵◔', '◔_◔', '◔︵◔'],
23
+ impatient: ['◕~◕', '⊙_⊙', '◕~◕', '⊙_⊙'],
13
24
  };
14
- // Frame speeds in ms
15
25
  const SPEEDS = {
16
- idle: 500,
17
- typing: 700,
18
- thinking: 120,
19
- success: 300,
20
- error: 400,
21
- jump: 70,
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,
22
30
  };
23
- export const MascotPrompt = ({ state }) => {
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, }) => {
24
53
  const frames = FACES[state];
25
54
  const [i, setI] = useState(0);
55
+ const thresholds = defaultIdleThresholds();
56
+ const idle = useIdle({
57
+ ...thresholds,
58
+ disabled: keepAwake || idleDisabled(),
59
+ });
26
60
  useEffect(() => {
27
61
  setI(0);
28
62
  if (frames.length <= 1)
@@ -30,9 +64,18 @@ export const MascotPrompt = ({ state }) => {
30
64
  const t = setInterval(() => setI((v) => (v + 1) % frames.length), SPEEDS[state]);
31
65
  return () => clearInterval(t);
32
66
  }, [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] }) }));
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] })] }));
38
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.14",
3
+ "version": "0.1.15",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",