openaxies 0.3.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,157 +31,169 @@ 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
+ const [isThinking, setIsThinking] = React.useState(false);
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);
59
67
 
60
68
  const commands = getCommands();
61
-
62
- // ---------------------------------------------------------------------------
63
- // Strict row budget
64
- // BrandHeader : BRAND_HEIGHT (7)
65
- // RouterBar : ROUTER_HEIGHT (2)
66
- // SlashOverlay : SLASH_OVERLAY_HEIGHT (6) — conditional
67
- // ComposerDock : DOCK_HEIGHT (3)
68
- // ChatViewport : rows - all fixed rows (flexGrow fills remainder)
69
- // ---------------------------------------------------------------------------
70
- const overlayH = showOverlay ? SLASH_OVERLAY_HEIGHT : 0;
71
- 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;
72
74
  const availLines = Math.max(1, rows - fixedH);
73
75
 
74
76
  const abortRef = React.useRef(null);
75
- const connectingRef = React.useRef(null);
76
-
77
- // ---------------------------------------------------------------------------
78
- // Input handlers
79
- // ---------------------------------------------------------------------------
80
-
81
- function handleInputChange(value) {
82
- const safeValue = typeof value === 'string' ? value : '';
83
- setInputBuffer(safeValue);
84
- // Open overlay on first '/' character
85
- if (safeValue.length === 1 && safeValue[0] === '/' && showOverlay === false) {
86
- setShowOverlay(true);
87
- setOverlayIndex(0);
88
- }
89
- // Close overlay if buffer no longer starts with '/'
90
- if (showOverlay === true && (safeValue.length === 0 || safeValue[0] !== '/')) {
91
- setShowOverlay(false);
92
- }
93
- }
94
-
95
- // onSubmit for TextInput — actual submit goes through useInput Enter handler.
96
- function handleInputSubmit() {
97
- return;
98
- }
99
-
100
- function handleSlashSelect(item) {
101
- if (item === null || item === undefined) {
102
- return;
103
- }
104
- const trigger = item.value;
105
- if (typeof trigger !== 'string') {
77
+ const connRef = React.useRef(null);
78
+ const spinnerRef = React.useRef(null);
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
+ ];
90
+
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
+ }, []);
102
+
103
+ React.useEffect(function () {
104
+ if (isThinking === false) {
105
+ if (spinnerRef.current !== null) {
106
+ clearInterval(spinnerRef.current);
107
+ spinnerRef.current = null;
108
+ }
109
+ if (timerRef.current !== null) {
110
+ clearInterval(timerRef.current);
111
+ timerRef.current = null;
112
+ }
113
+ setSpinnerChar('');
106
114
  return;
107
115
  }
116
+ let idx = 0;
117
+ setSpinnerChar(SPINNER_FRAMES[0]);
118
+ spinnerRef.current = setInterval(function () {
119
+ idx = (idx + 1) % SPINNER_FRAMES.length;
120
+ setSpinnerChar(SPINNER_FRAMES[idx]);
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 () {
128
+ if (spinnerRef.current !== null) {
129
+ clearInterval(spinnerRef.current);
130
+ spinnerRef.current = null;
131
+ }
132
+ if (timerRef.current !== null) {
133
+ clearInterval(timerRef.current);
134
+ timerRef.current = null;
135
+ }
136
+ };
137
+ }, [isThinking]);
138
+
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
+ });
149
+ }
108
150
 
109
- if (trigger === '/exit') {
110
- process.exit(0);
111
- }
112
-
113
- if (trigger === '/clear') {
114
- setMessages([]);
115
- setScrollOffset(0);
116
- }
117
-
118
- if (trigger === '/model') {
119
- // Toggle between the two local models
120
- setActiveModel(function (prev) {
121
- if (prev === 'openaxis/openaxis-mixtral') {
122
- return 'openaxis/openaxis-heavy';
123
- }
124
- return 'openaxis/openaxis-mixtral';
125
- });
126
- }
127
-
128
- if (trigger === '/help') {
129
- setScrollOffset(0);
130
- setMessages(function (prev) {
131
- return prev.concat([{
132
- role: 'assistant',
133
- content:
134
- '**Available commands:**\n' +
135
- '- `/help` — Show this help\n' +
136
- '- `/clear` — Clear the conversation\n' +
137
- '- `/model` — Toggle between Mixtral and Heavy\n' +
138
- '- `/exit` — Quit the application\n\n' +
139
- 'Type your message and press **Enter** to chat.',
140
- id: 'help-' + Date.now(),
141
- }]);
142
- });
143
- }
144
-
145
- // Always close & clear after any selection
146
- setInputBuffer('');
147
- setShowOverlay(false);
148
- 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;
149
156
  }
150
157
 
151
158
  async function handleSubmit(text) {
152
159
  const safeText = typeof text === 'string' ? text : '';
153
- if (safeText.length === 0 || streamingActive === true) {
154
- return;
155
- }
156
-
157
- const msgId = 'user-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
160
+ if (safeText.length === 0 || streamingActive === true) return;
158
161
 
159
162
  setStreamingActive(true);
160
- setScrollOffset(0);
161
- setMessages(function (prev) {
162
- return prev.concat([{ role: 'user', content: safeText, id: msgId }]);
163
- });
164
- setInputBuffer('');
163
+ setToolInfo(null);
165
164
  setStreamText(' connecting');
166
- connectingRef.current = setInterval(function () {
165
+ setThinkingElapsed(0);
166
+ connRef.current = setInterval(function () {
167
167
  setStreamText(function (prev) {
168
168
  if (prev === ' connecting') return ' connecting.';
169
169
  if (prev === ' connecting.') return ' connecting..';
170
170
  if (prev === ' connecting..') return ' connecting...';
171
- if (prev === ' connecting...') return ' connecting';
172
- return prev;
171
+ return ' connecting';
173
172
  });
174
173
  }, 400);
175
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
+
176
184
  const modelInfo = getModelById(activeModel);
177
- if (modelInfo === null || modelInfo === undefined) {
178
- if (connectingRef.current !== null) {
179
- clearInterval(connectingRef.current);
180
- connectingRef.current = null;
185
+ if (modelInfo === null) {
186
+ if (connRef.current !== null) {
187
+ clearInterval(connRef.current);
188
+ connRef.current = null;
181
189
  }
182
190
  setStreamingActive(false);
183
191
  setStreamText('');
184
- setScrollOffset(0);
185
192
  setMessages(function (prev) {
186
193
  return prev.concat([{
187
194
  role: 'assistant',
188
195
  content: 'No model selected. Use `/model` to switch.',
189
- id: 'err-' + Date.now(),
196
+ id: 'e-' + Date.now(),
190
197
  }]);
191
198
  });
192
199
  return;
@@ -200,91 +207,148 @@ function AppRoot() {
200
207
  const gen = callModel(modelInfo, history, abortController.signal);
201
208
  let accumulated = '';
202
209
  let firstEvent = true;
210
+ let thinkStarted = false;
203
211
 
204
212
  for await (const event of gen) {
205
- if (abortController.signal.aborted === true) {
206
- break;
207
- }
213
+ if (abortController.signal.aborted === true) break;
214
+
208
215
  if (firstEvent === true) {
209
216
  firstEvent = false;
210
- if (connectingRef.current !== null) {
211
- clearInterval(connectingRef.current);
212
- connectingRef.current = null;
217
+ if (connRef.current !== null) {
218
+ clearInterval(connRef.current);
219
+ connRef.current = null;
213
220
  }
214
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
+
215
233
  if (event.type === 'thinking' || event.type === 'token') {
216
234
  const content = typeof event.content === 'string' ? event.content : '';
217
235
  for (let i = 0; i < content.length; i++) {
218
236
  accumulated = accumulated + content[i];
219
- setScrollOffset(0);
237
+ const insideThink = checkThinkState(accumulated);
220
238
  setStreamText(accumulated);
221
- await new Promise(function (resolve) {
222
- setTimeout(resolve, 0);
223
- });
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);
249
+ }
250
+ await new Promise(function (r) { setTimeout(r, 0); });
224
251
  }
225
252
  }
226
- if (event.type === 'done') {
227
- break;
228
- }
253
+
254
+ if (event.type === 'done') break;
229
255
  if (event.type === 'timeout') {
230
256
  if (accumulated.length > 0) {
231
- accumulated = accumulated + '\n\n*[Response incomplete \u2014 stream timed out]*';
257
+ accumulated = accumulated + '\n\n\u2014 stream timed out \u2014';
232
258
  }
233
259
  setStreamText(accumulated);
234
260
  break;
235
261
  }
236
262
  }
237
263
 
238
- if (connectingRef.current !== null) {
239
- clearInterval(connectingRef.current);
240
- connectingRef.current = null;
264
+ if (connRef.current !== null) {
265
+ clearInterval(connRef.current);
266
+ connRef.current = null;
241
267
  }
268
+ setIsThinking(false);
269
+ thinkStartRef.current = null;
270
+ setThinkingElapsed(0);
242
271
  setStreamingActive(false);
272
+
273
+ const finalText = streamText;
243
274
  setStreamText('');
244
275
 
245
- if (accumulated.length > 0) {
276
+ if (finalText.length > 0) {
246
277
  setMessages(function (prev) {
247
278
  return prev.concat([{
248
279
  role: 'assistant',
249
- content: accumulated,
250
- id: 'resp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
280
+ content: finalText,
281
+ id: 'a-' + Date.now(),
251
282
  }]);
252
283
  });
253
284
  }
254
285
  } catch (err) {
255
- if (connectingRef.current !== null) {
256
- clearInterval(connectingRef.current);
257
- connectingRef.current = null;
286
+ if (connRef.current !== null) {
287
+ clearInterval(connRef.current);
288
+ connRef.current = null;
258
289
  }
290
+ setIsThinking(false);
291
+ thinkStartRef.current = null;
292
+ setThinkingElapsed(0);
259
293
  setStreamingActive(false);
260
294
  setStreamText('');
261
- setScrollOffset(0);
262
- const errMsg = (err !== null && err !== undefined)
295
+ const errMsg = err !== null && err !== undefined
263
296
  ? (err.message || String(err))
264
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') {
265
333
  setMessages(function (prev) {
266
334
  return prev.concat([{
267
335
  role: 'assistant',
268
336
  content:
269
- '**Connection Error:** ' + errMsg +
270
- '\n\nThe Space may be cold-starting (model download can take 30\u2013120 s). Please try again.',
271
- 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(),
272
343
  }]);
273
344
  });
274
345
  }
346
+ setInputBuffer('');
347
+ setShowOverlay(false);
348
+ setOverlayIndex(0);
275
349
  }
276
350
 
277
- // ---------------------------------------------------------------------------
278
- // Keyboard router
279
- //
280
- // Overlay mode: ↑↓ move cursor · Enter selects · Esc dismisses
281
- // All other keys are absorbed (TextInput has focus=false)
282
- //
283
- // Normal mode: Enter submits · Ctrl+C abort/quit
284
- // ---------------------------------------------------------------------------
285
351
  useInput(function handleInput(input, key) {
286
-
287
- // ── Overlay branch ──────────────────────────────────────────────────────
288
352
  if (showOverlay === true) {
289
353
  if (key.escape === true) {
290
354
  setShowOverlay(false);
@@ -292,63 +356,33 @@ function AppRoot() {
292
356
  setOverlayIndex(0);
293
357
  return;
294
358
  }
295
-
296
359
  if (key.upArrow === true) {
297
- setOverlayIndex(function (prev) {
298
- return prev > 0 ? prev - 1 : commands.length - 1; // wrap around top
299
- });
360
+ setOverlayIndex(function (p) { return p > 0 ? p - 1 : commands.length - 1; });
300
361
  return;
301
362
  }
302
-
303
363
  if (key.downArrow === true) {
304
- setOverlayIndex(function (prev) {
305
- return prev < commands.length - 1 ? prev + 1 : 0; // wrap around bottom
306
- });
364
+ setOverlayIndex(function (p) { return p < commands.length - 1 ? p + 1 : 0; });
307
365
  return;
308
366
  }
309
-
310
367
  if (key.return === true) {
311
- const selectedCmd = commands[overlayIndex];
312
- if (selectedCmd !== null && selectedCmd !== undefined) {
313
- handleSlashSelect({ value: selectedCmd.trigger });
314
- }
368
+ const cmd = commands[overlayIndex];
369
+ if (cmd !== null && cmd !== undefined) handleSlashSelect({ value: cmd.trigger });
315
370
  return;
316
371
  }
317
-
318
- // Absorb everything else while overlay is open
319
372
  return;
320
373
  }
321
374
 
322
- // ── Normal branch ────────────────────────────────────────────────────────
323
375
  if (key.return === true) {
324
- const safeValue = typeof inputBuffer === 'string' ? inputBuffer : '';
325
- if (safeValue.length > 0 && streamingActive === false) {
326
- handleSubmit(safeValue);
376
+ const safe = typeof inputBuffer === 'string' ? inputBuffer : '';
377
+ if (safe.length > 0 && streamingActive === false) {
378
+ handleSubmit(safe);
327
379
  }
328
380
  return;
329
381
  }
330
382
 
331
- if (key.pageUp === true || (key.ctrl === true && key.upArrow === true)) {
332
- const pageSize = Math.max(1, availLines - 2);
333
- setScrollOffset(function (prev) {
334
- return prev + pageSize;
335
- });
336
- return;
337
- }
338
-
339
- if (key.pageDown === true || (key.ctrl === true && key.downArrow === true)) {
340
- const pageSize = Math.max(1, availLines - 2);
341
- setScrollOffset(function (prev) {
342
- return Math.max(0, prev - pageSize);
343
- });
344
- return;
345
- }
346
-
347
383
  if (key.ctrl === true && (input === 'c' || input === 'C')) {
348
384
  if (abortRef.current !== null) {
349
- try {
350
- abortRef.current.abort();
351
- } catch (_) { }
385
+ try { abortRef.current.abort(); } catch (_) {}
352
386
  abortRef.current = null;
353
387
  setStreamingActive(false);
354
388
  setStreamText('');
@@ -357,27 +391,141 @@ function AppRoot() {
357
391
  }
358
392
  });
359
393
 
360
- // ---------------------------------------------------------------------------
361
- // Render
362
- // ---------------------------------------------------------------------------
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');
363
515
 
364
- const header = createBrandHeader(cols);
365
- const routerBar = createRouterBar(activeModel, cols);
366
- const viewport = createChatViewport(messages, streamText, availLines, cols, scrollOffset);
367
516
  const slashOverlay = showOverlay === true
368
517
  ? createSlashOverlay(commands, overlayIndex)
369
518
  : null;
519
+
370
520
  const dock = createComposerDock({
371
521
  value: inputBuffer,
372
522
  onChange: handleInputChange,
373
- onSubmit: handleInputSubmit,
374
- inputActive: showOverlay === false, // yield focus to overlay navigation
523
+ onSubmit: function () {},
524
+ inputActive: showOverlay === false,
375
525
  isStreaming: streamingActive,
376
526
  terminalWidth: cols,
377
527
  });
378
528
 
379
- // Strict column layout — total height === rows (no scroll, no overflow).
380
- // ComposerDock has flexShrink:0 so it is always pinned at bottom.
381
529
  return h(Box, {
382
530
  flexDirection: 'column',
383
531
  width: '100%',
@@ -385,10 +533,14 @@ function AppRoot() {
385
533
  backgroundColor: hex.black,
386
534
  overflow: 'hidden',
387
535
  },
388
- header, // 7 rows
389
- routerBar, // 2 rows
390
- viewport, // dynamic flexGrow fills all remaining rows
391
- slashOverlay, // 6 rows (null when closed)
392
- 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
393
543
  );
394
544
  }
545
+
546
+ import { createSlashOverlay } from './components/SlashOverlay.js';