openaxies 0.4.0 → 0.5.0

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/src/App.js CHANGED
@@ -1,23 +1,18 @@
1
1
  import React from 'react';
2
- import { Box, useInput, useStdout } from 'ink';
2
+ import { Box, Text, useInput, useStdout } from 'ink';
3
3
  import { hex } from './config/theme.js';
4
4
  import { getCommands } from './config/commands.js';
5
- import { getModelById, DEFAULT_MODEL_ID } from './config/models.js';
5
+ import { getModels, getModelById, DEFAULT_MODEL_ID } from './config/models.js';
6
6
  import { callModel } from './providers/index.js';
7
- import { createBrandHeader, BRAND_HEIGHT } from './components/BrandHeader.js';
8
- import { createRouterBar, ROUTER_HEIGHT } from './components/RouterBar.js';
9
- import { createChatViewport } from './components/ChatViewport.js';
10
- import { createSlashOverlay, SLASH_OVERLAY_HEIGHT } from './components/SlashOverlay.js';
7
+ import { createModelBar } from './components/ModelBar.js';
8
+ import { createParticleRow } from './components/Particles.js';
11
9
  import { createComposerDock, DOCK_HEIGHT } from './components/ComposerDock.js';
12
10
 
13
11
  const h = React.createElement;
12
+ const BAR_COLOR = '#1A1A2E';
14
13
 
15
14
  export default AppRoot;
16
15
 
17
- // ---------------------------------------------------------------------------
18
- // Helpers
19
- // ---------------------------------------------------------------------------
20
-
21
16
  function buildHistory(messages, newText) {
22
17
  const history = [];
23
18
  for (let i = 0; i < messages.length; i++) {
@@ -36,49 +31,74 @@ function buildHistory(messages, newText) {
36
31
  return history;
37
32
  }
38
33
 
39
- // ---------------------------------------------------------------------------
40
- // Root component
41
- // ---------------------------------------------------------------------------
34
+ function formatTimer(seconds) {
35
+ if (typeof seconds !== 'number' || seconds < 0) {
36
+ return '0.0s';
37
+ }
38
+ return seconds.toFixed(1) + 's';
39
+ }
40
+
41
+ function barLine(cols) {
42
+ const safeCols = typeof cols === 'number' && cols > 0 ? cols : 80;
43
+ let line = '';
44
+ for (let i = 0; i < safeCols; i++) {
45
+ line = line + '\u2500';
46
+ }
47
+ return h(Text, { color: BAR_COLOR }, line);
48
+ }
42
49
 
43
50
  function AppRoot() {
44
51
  const { stdout } = useStdout();
45
52
  const rows = stdout.rows || 24;
46
53
  const cols = stdout.columns || 80;
47
54
 
48
- // --- UI state ---
49
- const [inputBuffer, setInputBuffer] = React.useState('');
50
- const [showOverlay, setShowOverlay] = React.useState(false);
51
- const [overlayIndex, setOverlayIndex] = React.useState(0); // cursor inside overlay
52
-
53
- // --- Chat state ---
54
55
  const [messages, setMessages] = React.useState([]);
55
56
  const [activeModel, setActiveModel] = React.useState(DEFAULT_MODEL_ID);
56
57
  const [streamText, setStreamText] = React.useState('');
57
58
  const [streamingActive, setStreamingActive] = React.useState(false);
58
- const [scrollOffset, setScrollOffset] = React.useState(0);
59
59
  const [isThinking, setIsThinking] = React.useState(false);
60
60
  const [spinnerChar, setSpinnerChar] = React.useState('');
61
+ const [thinkingElapsed, setThinkingElapsed] = React.useState(0);
62
+ const [inputBuffer, setInputBuffer] = React.useState('');
63
+ const [showOverlay, setShowOverlay] = React.useState(false);
64
+ const [overlayIndex, setOverlayIndex] = React.useState(0);
65
+ const [particleFrame, setParticleFrame] = React.useState(0);
66
+ const [toolInfo, setToolInfo] = React.useState(null);
61
67
 
62
68
  const commands = getCommands();
63
-
64
- // ---------------------------------------------------------------------------
65
- // Strict row budget
66
- // BrandHeader : BRAND_HEIGHT (7)
67
- // RouterBar : ROUTER_HEIGHT (2)
68
- // SlashOverlay : SLASH_OVERLAY_HEIGHT (6) — conditional
69
- // ComposerDock : DOCK_HEIGHT (3)
70
- // ChatViewport : rows - all fixed rows (flexGrow fills remainder)
71
- // ---------------------------------------------------------------------------
72
- const overlayH = showOverlay ? SLASH_OVERLAY_HEIGHT : 0;
73
- const fixedH = BRAND_HEIGHT + ROUTER_HEIGHT + overlayH + DOCK_HEIGHT;
69
+ const MODEL_BAR_H = 1;
70
+ const SEP_H = 1;
71
+ const DOCK = DOCK_HEIGHT;
72
+ const overlayH = showOverlay ? 6 : 0;
73
+ const fixedH = MODEL_BAR_H + SEP_H + overlayH + DOCK;
74
74
  const availLines = Math.max(1, rows - fixedH);
75
75
 
76
76
  const abortRef = React.useRef(null);
77
- const connectingRef = React.useRef(null);
77
+ const connRef = React.useRef(null);
78
78
  const spinnerRef = React.useRef(null);
79
- const thinkingAccumRef = React.useRef('');
79
+ const timerRef = React.useRef(null);
80
+ const thinkStartRef = React.useRef(null);
81
+ const particleRef = React.useRef(null);
82
+
83
+ const SPINNER_FRAMES = [
84
+ '\u25CB', '\u25D4', '\u25D0', '\u25D5', '\u25CF',
85
+ '\u25D5', '\u25D0', '\u25D4', '\u25CB', '\u25D1',
86
+ '\u25D2', '\u25D3', '\u25CF', '\u25D3', '\u25D2',
87
+ '\u25D1', '\u25CC', '\u25CB', '\u25CD', '\u25CE',
88
+ '\u25CF', '\u25CE', '\u25CD', '\u25CB',
89
+ ];
80
90
 
81
- const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
91
+ React.useEffect(function () {
92
+ particleRef.current = setInterval(function () {
93
+ setParticleFrame(function (p) { return (p + 1) % 64; });
94
+ }, 100);
95
+ return function () {
96
+ if (particleRef.current !== null) {
97
+ clearInterval(particleRef.current);
98
+ particleRef.current = null;
99
+ }
100
+ };
101
+ }, []);
82
102
 
83
103
  React.useEffect(function () {
84
104
  if (isThinking === false) {
@@ -86,6 +106,10 @@ function AppRoot() {
86
106
  clearInterval(spinnerRef.current);
87
107
  spinnerRef.current = null;
88
108
  }
109
+ if (timerRef.current !== null) {
110
+ clearInterval(timerRef.current);
111
+ timerRef.current = null;
112
+ }
89
113
  setSpinnerChar('');
90
114
  return;
91
115
  }
@@ -94,128 +118,82 @@ function AppRoot() {
94
118
  spinnerRef.current = setInterval(function () {
95
119
  idx = (idx + 1) % SPINNER_FRAMES.length;
96
120
  setSpinnerChar(SPINNER_FRAMES[idx]);
97
- }, 120);
98
- return function cleanup() {
121
+ }, 100);
122
+ timerRef.current = setInterval(function () {
123
+ if (thinkStartRef.current !== null) {
124
+ setThinkingElapsed((Date.now() - thinkStartRef.current) / 1000);
125
+ }
126
+ }, 100);
127
+ return function () {
99
128
  if (spinnerRef.current !== null) {
100
129
  clearInterval(spinnerRef.current);
101
130
  spinnerRef.current = null;
102
131
  }
132
+ if (timerRef.current !== null) {
133
+ clearInterval(timerRef.current);
134
+ timerRef.current = null;
135
+ }
103
136
  };
104
137
  }, [isThinking]);
105
138
 
106
- // ---------------------------------------------------------------------------
107
- // Input handlers
108
- // ---------------------------------------------------------------------------
109
-
110
- function handleInputChange(value) {
111
- const safeValue = typeof value === 'string' ? value : '';
112
- setInputBuffer(safeValue);
113
- // Open overlay on first '/' character
114
- if (safeValue.length === 1 && safeValue[0] === '/' && showOverlay === false) {
115
- setShowOverlay(true);
116
- setOverlayIndex(0);
117
- }
118
- // Close overlay if buffer no longer starts with '/'
119
- if (showOverlay === true && (safeValue.length === 0 || safeValue[0] !== '/')) {
120
- setShowOverlay(false);
121
- }
139
+ function cycleModel() {
140
+ const models = getModels();
141
+ setActiveModel(function (prev) {
142
+ let found = false;
143
+ for (let i = 0; i < models.length; i++) {
144
+ if (found) return models[i].id;
145
+ if (models[i].id === prev) found = true;
146
+ }
147
+ return models[0].id;
148
+ });
122
149
  }
123
150
 
124
- // onSubmit for TextInput — actual submit goes through useInput Enter handler.
125
- function handleInputSubmit() {
126
- return;
127
- }
128
-
129
- function handleSlashSelect(item) {
130
- if (item === null || item === undefined) {
131
- return;
132
- }
133
- const trigger = item.value;
134
- if (typeof trigger !== 'string') {
135
- return;
136
- }
137
-
138
- if (trigger === '/exit') {
139
- process.exit(0);
140
- }
141
-
142
- if (trigger === '/clear') {
143
- setMessages([]);
144
- setScrollOffset(0);
145
- }
146
-
147
- if (trigger === '/model') {
148
- // Toggle between the two local models
149
- setActiveModel(function (prev) {
150
- if (prev === 'openaxis/openaxis-flash') {
151
- return 'openaxis/openaxis-heavy';
152
- }
153
- return 'openaxis/openaxis-flash';
154
- });
155
- }
156
-
157
- if (trigger === '/help') {
158
- setScrollOffset(0);
159
- setMessages(function (prev) {
160
- return prev.concat([{
161
- role: 'assistant',
162
- content:
163
- '**Available commands:**\n' +
164
- '- `/help` — Show this help\n' +
165
- '- `/clear` — Clear the conversation\n' +
166
- '- `/model` — Toggle between Flash and Heavy\n' +
167
- '- `/exit` — Quit the application\n\n' +
168
- 'Type your message and press **Enter** to chat.',
169
- id: 'help-' + Date.now(),
170
- }]);
171
- });
172
- }
173
-
174
- // Always close & clear after any selection
175
- setInputBuffer('');
176
- setShowOverlay(false);
177
- setOverlayIndex(0);
151
+ function checkThinkState(text) {
152
+ const openIdx = text.lastIndexOf('<think>');
153
+ const closeIdx = text.lastIndexOf('</think>');
154
+ if (openIdx === -1 && closeIdx === -1) return false;
155
+ return openIdx > closeIdx;
178
156
  }
179
157
 
180
158
  async function handleSubmit(text) {
181
159
  const safeText = typeof text === 'string' ? text : '';
182
- if (safeText.length === 0 || streamingActive === true) {
183
- return;
184
- }
185
-
186
- const msgId = 'user-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
160
+ if (safeText.length === 0 || streamingActive === true) return;
187
161
 
188
162
  setStreamingActive(true);
189
- setScrollOffset(0);
190
- setMessages(function (prev) {
191
- return prev.concat([{ role: 'user', content: safeText, id: msgId }]);
192
- });
193
- setInputBuffer('');
163
+ setToolInfo(null);
194
164
  setStreamText(' connecting');
195
- connectingRef.current = setInterval(function () {
165
+ setThinkingElapsed(0);
166
+ connRef.current = setInterval(function () {
196
167
  setStreamText(function (prev) {
197
168
  if (prev === ' connecting') return ' connecting.';
198
169
  if (prev === ' connecting.') return ' connecting..';
199
170
  if (prev === ' connecting..') return ' connecting...';
200
- if (prev === ' connecting...') return ' connecting';
201
- return prev;
171
+ return ' connecting';
202
172
  });
203
173
  }, 400);
204
174
 
175
+ setMessages(function (prev) {
176
+ return prev.concat([{
177
+ role: 'user',
178
+ content: safeText,
179
+ id: 'u-' + Date.now(),
180
+ }]);
181
+ });
182
+ setInputBuffer('');
183
+
205
184
  const modelInfo = getModelById(activeModel);
206
- if (modelInfo === null || modelInfo === undefined) {
207
- if (connectingRef.current !== null) {
208
- clearInterval(connectingRef.current);
209
- connectingRef.current = null;
185
+ if (modelInfo === null) {
186
+ if (connRef.current !== null) {
187
+ clearInterval(connRef.current);
188
+ connRef.current = null;
210
189
  }
211
190
  setStreamingActive(false);
212
191
  setStreamText('');
213
- setScrollOffset(0);
214
192
  setMessages(function (prev) {
215
193
  return prev.concat([{
216
194
  role: 'assistant',
217
195
  content: 'No model selected. Use `/model` to switch.',
218
- id: 'err-' + Date.now(),
196
+ id: 'e-' + Date.now(),
219
197
  }]);
220
198
  });
221
199
  return;
@@ -226,115 +204,151 @@ function AppRoot() {
226
204
  abortRef.current = abortController;
227
205
 
228
206
  try {
229
- function checkThinkState(text) {
230
- const openIdx = text.lastIndexOf('<think>');
231
- const closeIdx = text.lastIndexOf('</think>');
232
- if (openIdx === -1 && closeIdx === -1) {
233
- return false;
234
- }
235
- if (openIdx > closeIdx) {
236
- return true;
237
- }
238
- return false;
239
- }
240
-
241
207
  const gen = callModel(modelInfo, history, abortController.signal);
242
208
  let accumulated = '';
243
209
  let firstEvent = true;
210
+ let thinkStarted = false;
244
211
 
245
212
  for await (const event of gen) {
246
- if (abortController.signal.aborted === true) {
247
- break;
248
- }
213
+ if (abortController.signal.aborted === true) break;
214
+
249
215
  if (firstEvent === true) {
250
216
  firstEvent = false;
251
- if (connectingRef.current !== null) {
252
- clearInterval(connectingRef.current);
253
- connectingRef.current = null;
217
+ if (connRef.current !== null) {
218
+ clearInterval(connRef.current);
219
+ connRef.current = null;
254
220
  }
255
221
  }
222
+
223
+ if (event.type === 'tool') {
224
+ setToolInfo({
225
+ tool: event.tool,
226
+ used: event.used,
227
+ sites: event.sites,
228
+ query: event.query,
229
+ });
230
+ continue;
231
+ }
232
+
256
233
  if (event.type === 'thinking' || event.type === 'token') {
257
234
  const content = typeof event.content === 'string' ? event.content : '';
258
235
  for (let i = 0; i < content.length; i++) {
259
236
  accumulated = accumulated + content[i];
260
- const isInsideThink = checkThinkState(accumulated);
261
- setScrollOffset(0);
237
+ const insideThink = checkThinkState(accumulated);
262
238
  setStreamText(accumulated);
263
- setIsThinking(isInsideThink);
264
- if (isInsideThink === true) {
265
- thinkingAccumRef.current = accumulated;
239
+ setIsThinking(insideThink);
240
+ if (insideThink === true && thinkStarted === false) {
241
+ thinkStarted = true;
242
+ thinkStartRef.current = Date.now();
243
+ setThinkingElapsed(0);
244
+ }
245
+ if (insideThink === false && thinkStarted === true) {
246
+ thinkStarted = false;
247
+ thinkStartRef.current = null;
248
+ setThinkingElapsed(0);
266
249
  }
267
- await new Promise(function (resolve) {
268
- setTimeout(resolve, 0);
269
- });
250
+ await new Promise(function (r) { setTimeout(r, 0); });
270
251
  }
271
252
  }
272
- if (event.type === 'done') {
273
- break;
274
- }
253
+
254
+ if (event.type === 'done') break;
275
255
  if (event.type === 'timeout') {
276
256
  if (accumulated.length > 0) {
277
- accumulated = accumulated + '\n\n*[Response incomplete \u2014 stream timed out]*';
257
+ accumulated = accumulated + '\n\n\u2014 stream timed out \u2014';
278
258
  }
279
259
  setStreamText(accumulated);
280
260
  break;
281
261
  }
282
262
  }
283
263
 
284
- if (connectingRef.current !== null) {
285
- clearInterval(connectingRef.current);
286
- connectingRef.current = null;
264
+ if (connRef.current !== null) {
265
+ clearInterval(connRef.current);
266
+ connRef.current = null;
287
267
  }
288
268
  setIsThinking(false);
289
- thinkingAccumRef.current = '';
269
+ thinkStartRef.current = null;
270
+ setThinkingElapsed(0);
290
271
  setStreamingActive(false);
272
+
273
+ const finalText = streamText;
291
274
  setStreamText('');
292
275
 
293
- if (accumulated.length > 0) {
276
+ if (finalText.length > 0) {
294
277
  setMessages(function (prev) {
295
278
  return prev.concat([{
296
279
  role: 'assistant',
297
- content: accumulated,
298
- id: 'resp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
280
+ content: finalText,
281
+ id: 'a-' + Date.now(),
299
282
  }]);
300
283
  });
301
284
  }
302
285
  } catch (err) {
303
- if (connectingRef.current !== null) {
304
- clearInterval(connectingRef.current);
305
- connectingRef.current = null;
286
+ if (connRef.current !== null) {
287
+ clearInterval(connRef.current);
288
+ connRef.current = null;
306
289
  }
307
290
  setIsThinking(false);
308
- thinkingAccumRef.current = '';
291
+ thinkStartRef.current = null;
292
+ setThinkingElapsed(0);
309
293
  setStreamingActive(false);
310
294
  setStreamText('');
311
- setScrollOffset(0);
312
- const errMsg = (err !== null && err !== undefined)
295
+ const errMsg = err !== null && err !== undefined
313
296
  ? (err.message || String(err))
314
297
  : 'Unknown error';
298
+ setMessages(function (prev) {
299
+ return prev.concat([{
300
+ role: 'assistant',
301
+ content: '**Error:** ' + errMsg + '\n\nThe Space may be cold-starting (30\u2013120 s first request). Try again.',
302
+ id: 'er-' + Date.now(),
303
+ }]);
304
+ });
305
+ }
306
+ }
307
+
308
+ function handleInputChange(v) {
309
+ const safe = typeof v === 'string' ? v : '';
310
+ setInputBuffer(safe);
311
+ if (safe.length === 1 && safe[0] === '/' && showOverlay === false) {
312
+ setShowOverlay(true);
313
+ setOverlayIndex(0);
314
+ }
315
+ if (showOverlay === true && (safe.length === 0 || safe[0] !== '/')) {
316
+ setShowOverlay(false);
317
+ }
318
+ }
319
+
320
+ function handleSlashSelect(item) {
321
+ if (item === null || item === undefined) return;
322
+ const trigger = item.value;
323
+ if (typeof trigger !== 'string') return;
324
+
325
+ if (trigger === '/exit') process.exit(0);
326
+ if (trigger === '/clear') {
327
+ setMessages([]);
328
+ }
329
+ if (trigger === '/model') {
330
+ cycleModel();
331
+ }
332
+ if (trigger === '/help') {
315
333
  setMessages(function (prev) {
316
334
  return prev.concat([{
317
335
  role: 'assistant',
318
336
  content:
319
- '**Connection Error:** ' + errMsg +
320
- '\n\nThe Space may be cold-starting (model download can take 30\u2013120 s). Please try again.',
321
- id: 'err-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
337
+ '**Commands:**\n' +
338
+ '- `/help` \u2014 this help\n' +
339
+ '- `/clear` \u2014 clear chat\n' +
340
+ '- `/model` \u2014 cycle: Llama \u2192 GPT \u2192 DeepSeek\n' +
341
+ '- `/exit` \u2014 quit',
342
+ id: 'hlp-' + Date.now(),
322
343
  }]);
323
344
  });
324
345
  }
346
+ setInputBuffer('');
347
+ setShowOverlay(false);
348
+ setOverlayIndex(0);
325
349
  }
326
350
 
327
- // ---------------------------------------------------------------------------
328
- // Keyboard router
329
- //
330
- // Overlay mode: ↑↓ move cursor · Enter selects · Esc dismisses
331
- // All other keys are absorbed (TextInput has focus=false)
332
- //
333
- // Normal mode: Enter submits · Ctrl+C abort/quit
334
- // ---------------------------------------------------------------------------
335
351
  useInput(function handleInput(input, key) {
336
-
337
- // ── Overlay branch ──────────────────────────────────────────────────────
338
352
  if (showOverlay === true) {
339
353
  if (key.escape === true) {
340
354
  setShowOverlay(false);
@@ -342,63 +356,33 @@ function AppRoot() {
342
356
  setOverlayIndex(0);
343
357
  return;
344
358
  }
345
-
346
359
  if (key.upArrow === true) {
347
- setOverlayIndex(function (prev) {
348
- return prev > 0 ? prev - 1 : commands.length - 1; // wrap around top
349
- });
360
+ setOverlayIndex(function (p) { return p > 0 ? p - 1 : commands.length - 1; });
350
361
  return;
351
362
  }
352
-
353
363
  if (key.downArrow === true) {
354
- setOverlayIndex(function (prev) {
355
- return prev < commands.length - 1 ? prev + 1 : 0; // wrap around bottom
356
- });
364
+ setOverlayIndex(function (p) { return p < commands.length - 1 ? p + 1 : 0; });
357
365
  return;
358
366
  }
359
-
360
367
  if (key.return === true) {
361
- const selectedCmd = commands[overlayIndex];
362
- if (selectedCmd !== null && selectedCmd !== undefined) {
363
- handleSlashSelect({ value: selectedCmd.trigger });
364
- }
368
+ const cmd = commands[overlayIndex];
369
+ if (cmd !== null && cmd !== undefined) handleSlashSelect({ value: cmd.trigger });
365
370
  return;
366
371
  }
367
-
368
- // Absorb everything else while overlay is open
369
372
  return;
370
373
  }
371
374
 
372
- // ── Normal branch ────────────────────────────────────────────────────────
373
375
  if (key.return === true) {
374
- const safeValue = typeof inputBuffer === 'string' ? inputBuffer : '';
375
- if (safeValue.length > 0 && streamingActive === false) {
376
- handleSubmit(safeValue);
376
+ const safe = typeof inputBuffer === 'string' ? inputBuffer : '';
377
+ if (safe.length > 0 && streamingActive === false) {
378
+ handleSubmit(safe);
377
379
  }
378
380
  return;
379
381
  }
380
382
 
381
- if (key.pageUp === true || (key.ctrl === true && key.upArrow === true)) {
382
- const pageSize = Math.max(1, availLines - 2);
383
- setScrollOffset(function (prev) {
384
- return prev + pageSize;
385
- });
386
- return;
387
- }
388
-
389
- if (key.pageDown === true || (key.ctrl === true && key.downArrow === true)) {
390
- const pageSize = Math.max(1, availLines - 2);
391
- setScrollOffset(function (prev) {
392
- return Math.max(0, prev - pageSize);
393
- });
394
- return;
395
- }
396
-
397
383
  if (key.ctrl === true && (input === 'c' || input === 'C')) {
398
384
  if (abortRef.current !== null) {
399
- try {
400
- abortRef.current.abort();
401
- } catch (_) { }
385
+ try { abortRef.current.abort(); } catch (_) {}
402
386
  abortRef.current = null;
403
387
  setStreamingActive(false);
404
388
  setStreamText('');
@@ -407,27 +391,141 @@ function AppRoot() {
407
391
  }
408
392
  });
409
393
 
410
- // ---------------------------------------------------------------------------
411
- // Render
412
- // ---------------------------------------------------------------------------
394
+ const modelBar = createModelBar(activeModel, cols);
395
+ const particleTop = createParticleRow(particleFrame, cols, 'tp');
396
+
397
+ const viewLines = [];
398
+ for (let i = 0; i < messages.length; i++) {
399
+ const msg = messages[i];
400
+ if (msg === null || typeof msg !== 'object') continue;
401
+ const content = typeof msg.content === 'string' ? msg.content : '';
402
+ if (content.length === 0) continue;
403
+
404
+ if (i > 0) viewLines.push({ type: 'sep' });
405
+
406
+ if (msg.role === 'user') {
407
+ viewLines.push({ type: 'user', text: content });
408
+ viewLines.push({ type: 'sep' });
409
+ } else {
410
+ viewLines.push({ type: 'assistant', text: content });
411
+ }
412
+ }
413
+
414
+ const hasStream = typeof streamText === 'string' && streamText.length > 0;
415
+ if (hasStream === true) {
416
+ viewLines.push({ type: 'sep' });
417
+
418
+ if (toolInfo !== null && toolInfo.used === true) {
419
+ viewLines.push({
420
+ type: 'tool',
421
+ text: ' \u2500\u2500 ' + toolInfo.tool + ': "' + toolInfo.query + '" (' + toolInfo.sites + ' sites)',
422
+ });
423
+ }
424
+
425
+ if (isThinking === true) {
426
+ const sp = typeof spinnerChar === 'string' && spinnerChar.length > 0 ? spinnerChar : '\u25CB';
427
+ viewLines.push({
428
+ type: 'think',
429
+ spin: sp,
430
+ elapsed: formatTimer(thinkingElapsed),
431
+ });
432
+ const clean = streamText.split('<think>').join('').split('</think>').join('');
433
+ viewLines.push({ type: 'stream', text: clean, color: '#FF9F43' });
434
+ } else {
435
+ const clean = streamText.split('<think>').join('').split('</think>').join('');
436
+ viewLines.push({ type: 'stream', text: clean, color: hex.primary });
437
+ }
438
+ }
439
+
440
+ if (viewLines.length === 0) {
441
+ viewLines.push({ type: 'muted', text: ' type a message to start...' });
442
+ }
443
+
444
+ const visibleHeight = Math.max(1, availLines - 1);
445
+ const displayLines = viewLines.slice(-visibleHeight);
446
+ const viewElements = [];
447
+
448
+ for (let i = 0; i < displayLines.length; i++) {
449
+ const line = displayLines[i];
450
+ if (line.type === 'sep') {
451
+ viewElements.push(h(Box, { key: 'vs-' + i, height: 1 }, barLine(cols)));
452
+ } else if (line.type === 'think') {
453
+ viewElements.push(
454
+ h(Box, { key: 'vt-' + i, height: 1 },
455
+ h(Text, { color: '#FF9F43', bold: true },
456
+ ' ' + line.spin + ' Thinking \u2022 ' + line.elapsed
457
+ )
458
+ )
459
+ );
460
+ } else if (line.type === 'tool') {
461
+ viewElements.push(
462
+ h(Box, { key: 'vtl-' + i, height: 1 },
463
+ h(Text, { color: '#5B5B8A' }, line.text)
464
+ )
465
+ );
466
+ } else if (line.type === 'user') {
467
+ viewElements.push(
468
+ h(Box, {
469
+ key: 'vu-' + i,
470
+ height: 1,
471
+ paddingLeft: 1,
472
+ },
473
+ h(Text, { color: hex.neonBlue, bold: true }, '\u25B7 '),
474
+ h(Text, { color: hex.neonBlue }, line.text)
475
+ )
476
+ );
477
+ } else if (line.type === 'assistant') {
478
+ viewElements.push(
479
+ h(Box, {
480
+ key: 'va-' + i,
481
+ height: 1,
482
+ paddingLeft: 1,
483
+ },
484
+ h(Text, { color: hex.primary }, line.text)
485
+ )
486
+ );
487
+ } else if (line.type === 'stream') {
488
+ viewElements.push(
489
+ h(Box, {
490
+ key: 'vst-' + i,
491
+ height: 1,
492
+ paddingLeft: 1,
493
+ },
494
+ h(Text, { color: line.color }, line.text)
495
+ )
496
+ );
497
+ } else if (line.type === 'muted') {
498
+ viewElements.push(
499
+ h(Box, { key: 'vm-' + i, height: 1, paddingLeft: 1 },
500
+ h(Text, { color: '#333355' }, line.text)
501
+ )
502
+ );
503
+ }
504
+ }
505
+
506
+ const viewport = h(Box, {
507
+ flexGrow: 1,
508
+ height: availLines,
509
+ flexDirection: 'column',
510
+ overflow: 'hidden',
511
+ paddingBottom: 1,
512
+ }, ...viewElements);
513
+
514
+ const particleBot = createParticleRow(particleFrame, cols, 'bt');
413
515
 
414
- const header = createBrandHeader(cols);
415
- const routerBar = createRouterBar(activeModel, cols);
416
- const viewport = createChatViewport(messages, streamText, availLines, cols, scrollOffset, isThinking, spinnerChar);
417
516
  const slashOverlay = showOverlay === true
418
517
  ? createSlashOverlay(commands, overlayIndex)
419
518
  : null;
519
+
420
520
  const dock = createComposerDock({
421
521
  value: inputBuffer,
422
522
  onChange: handleInputChange,
423
- onSubmit: handleInputSubmit,
424
- inputActive: showOverlay === false, // yield focus to overlay navigation
523
+ onSubmit: function () {},
524
+ inputActive: showOverlay === false,
425
525
  isStreaming: streamingActive,
426
526
  terminalWidth: cols,
427
527
  });
428
528
 
429
- // Strict column layout — total height === rows (no scroll, no overflow).
430
- // ComposerDock has flexShrink:0 so it is always pinned at bottom.
431
529
  return h(Box, {
432
530
  flexDirection: 'column',
433
531
  width: '100%',
@@ -435,10 +533,14 @@ function AppRoot() {
435
533
  backgroundColor: hex.black,
436
534
  overflow: 'hidden',
437
535
  },
438
- header, // 7 rows
439
- routerBar, // 2 rows
440
- viewport, // dynamic flexGrow fills all remaining rows
441
- slashOverlay, // 6 rows (null when closed)
442
- dock // 3 rows flexShrink:0, always at bottom
536
+ modelBar,
537
+ h(Box, { height: 1 }, barLine(cols)),
538
+ h(Box, { height: 1, paddingLeft: 1 }, particleTop),
539
+ viewport,
540
+ h(Box, { height: 1, paddingLeft: 1 }, particleBot),
541
+ slashOverlay,
542
+ dock
443
543
  );
444
544
  }
545
+
546
+ import { createSlashOverlay } from './components/SlashOverlay.js';